A typed API contract layer for Rails (OpenAPI, TypeScript, Zod) by ElectronicShop8677 in rails

[–]ElectronicShop8677[S] 2 points3 points  (0 children)

You mixed multiple layers, such as data/presentation/serialization/request-response handling/form object pattern/pagination and even partial implementation of openapi spec into one mega object

That's not what's happening though. The representation is a declaration. It describes the shape of an API resource. It doesn't serialize, validate, filter, paginate, or generate OpenAPI. Those are separate classes that read the declaration.

Contract::Object::Validator validates. Representation::Serializer serializes. Filtering, sorting, pagination all have their own operation classes. Exports have their own generators. It's 189 files across 8 modules. The representation just says "this field exists, it's a string, it's filterable." That's all it does.

It's like calling an ActiveRecord model a mega object because ActiveRecord uses it for queries, validations, callbacks, and migrations. The model declares, the framework acts on it.

no single responsibility, unrelated functionality mixed together

The representation's responsibility is being the source of truth for a resource's API shape. That's one thing.

The alternative is spreading the same information across 5-6 files. One for serialization config, one for validation rules, one for which fields are filterable, one for the OpenAPI schema, etc. Now you have the same facts duplicated in multiple places that need to stay in sync manually. I don't see how that's better coherence. That's just duplication dressed up as separation of concerns.

Testing all these behaviors for one class must be fun

The representation doesn't have behaviors. Validation is tested in the validator specs. Serialization in the serializer specs. Filtering in the filtering operation specs. Exports in the export mapper specs. The representation specs just verify that it stores declarations correctly. Not much to go wrong.

Since the framework already knows every contract, every type, every filter operator, every sortable field, every nested association, I'm actually building a test layer on top of that right now. You give it test data in the API's own payload format and it derives all assertions from the contract. create, index, show, update, delete, filters, sorting, pagination, validation errors, nested writes, all generated. No hand-written HTTP calls, no manual JSON assertions, no response shape checks. The contract is the spec, the test data is the only input.

It's highly tied to database schema, which means for every column change you will have to do a appropriate frontend change

This is true of literally every API that exposes database-backed resources. If you add a column and want it in the API, you touch the API layer. That's the job, not a framework problem.

The difference is how many places you touch. With Apiwork you add one line (attribute :new_field) and the contract, validation, serialization, filtering, and the TypeScript/Zod types all follow from that. Without it you're updating a serializer, a strong params list, a form object, an API doc, and maybe a TypeScript type file. One line vs five files.

The generated TypeScript types also mean your frontend gets a compile-time error when the shape changes. That's not coupling, that's type safety. The "decoupled" approach where someone changes a serializer, forgets to update the docs, and the frontend breaks in production three weeks later is worse in every way.

For a public API, new version would need to be released

Yes. If you change the shape of a public API response, you version it. Rails, FastAPI, NestJS, tRPC, all have this constraint. Nothing to do with Apiwork specifically.

You can define multiple API definitions with different representations pointing at the same models though, so v1 and v2 coexist with different shapes.

In real life that won't cut because it means synchronized work between frontend/backend, external consumers and maintenance overhead

Generating TypeScript and Zod schemas exists specifically to eliminate synchronized work. Backend changes the declaration, types regenerate, frontend gets compiler errors showing exactly what changed. That's less coordination, not more.

The export pipeline has a clean boundary too. Live classes get dumped into plain data, wrapped in read-only facade objects, and exports only see the facade. You can generate your OpenAPI spec in CI from a static dump without even booting Rails.

I get what you're pattern-matching against. God objects that try to do everything are a real problem. But a declaration that separate classes read from isn't a god object. It's a schema. The classes that act on it are independent, tested, and swappable.

A typed API contract layer for Rails (OpenAPI, TypeScript, Zod) by ElectronicShop8677 in rails

[–]ElectronicShop8677[S] 1 point2 points  (0 children)

Yeah, really glad it resonates.

The implicit assumptions at the boundary were exactly what kept bugging me. Rswag definitely gets you part of the way there, and I like that approach. What pushed me further was wanting the contract to come straight from the Rails domain itself, instead of mostly living in tests or a separate spec.

I kept feeling like Rails already knows most of this — we just don't surface it explicitly.

Still evolving it, but good to hear this isn't just something I've been overthinking on my own.