
Code as schema: how we generate the Increase API
The Increase API sits at the heart of our product and we want to make it great. For an API, greatness often lies in small details - are resources and fields named consistently? Does the API’s production behavior match i…
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
The Increase API sits at the heart of our product and we want to make it great. For an API, greatness often lies in small details - are resources and fields named consistently? Does the API’s production behavior match its documentation? How helpful are the error messages? Are there language-specific SDKs available? Do they have strong types?
Holding a high quality bar as your API grows can be a ton of work, often requiring a dedicated team. That’s not how we like to work. We’ve instead invested lots of energy in an opinionated internal framework for API definition. It’s allowed a single engineer to own the developer experience (APIs, SDKs, documentation, etc) for all of Increase, even as our API has scaled to hundreds of endpoints. We think our framework is good and would like to extol its virtues to you.
Every API method implements a common interface and must define an HTTP verb, path, parameter shape, and response shape. Here’s a simplified Account API (these examples are in Ruby, but you can write something similar in any language):
class AccountResource < APISchema::AbstractResource string :id, documentation: "The Account identifier" string :name, documentation: "The name of the Account" enum :funding, documentation: "The type of the Account", cases: { deposits: "A depository account such as checking or savings", loan: "A loan account", } timestamp :created_at, documentation: "When the Account was created" end
class AccountCreate < APISchema::AbstractAPIMethod signature do http_path '/accounts' http_verb :post documentation 'Create an Account' parameters do string :name, documentation: AccountResource.attributes.name.documentation enum :funding, documentation: AccountResource.attributes.funding.documentation, cases: AccountResource.attributes.funding.cases, optional_with_default: :deposits end returns AccountResource end
def operate account = Model::Account.create!(name: params.name, funding: params.funding) AccountResource.new( id: account.id, created_at: account.created_at, name: account.name, funding: account.funding, ) end end
We try to create a “pit of success” with the framework, both in defining the API shape and then working with it:
For example, if you now want to add a method to retrieve an Account, there’s a shorthand for parameters that are resource IDs. First, we associate AccountResource with the database model it should load automagically:
class AccountResource < APISchema::AbstractResource
... attribute definitions from above
associated_model Model::Account end
Then the GET method looks like:
class AccountRetrieve < APISchema::AbstractAPIMethod signature do http_path '/accounts/{account_id}' http_verb :get documentation 'Retrieve an Account' parameters do resource_id :account_id, resource: AccountResource, documentation: "The ID of the Account to be retrieved" end returns AccountResource # It's nice to be able to reuse these across different methods end
def operate account = params.account # the framework hands us a fully-hydrated Model::Account AccountResource.from_model(account) # The associated_model call gives us a convenience method here now end end
Under the hood, resource_id safely loads the Model::Account with that ID and hydrates it into params.account. Our type system knows params.account is a Model::Account, params.funding is an enum with exactly those two cases, and AccountResource.new takes an object that implements the attributes declared above.
We push as much validation into the framework as possible and try to keep each operate method thin. This gives us a lot of developer experience features for free. The framework can:
Consistency and expressive errors help make a great API. Having an opinionated framework like this helps us keep those properties as we grow.
Validation and API definition is only half of the framework’s job. The real magic is turning the code definition into a portable schema that can be used by tools in any language. For us, that is OpenAPI 3.1, which has issues but is the least bad option we’ve found. Our API framework uses a big pile of Ruby reflection to translate our statically defined API shape into a high-quality OpenAPI schema. (Some teams start by hand-writing an OpenAPI schema and code-generating server-side stubs for implementation. We think it’s impossible to maintain consistency and quality in a hand-written schema of this size.)
There’s an art to making a good OpenAPI schema, and the framework guides engineers toward success here too. Because we reuse AccountResource across multiple API methods, the OpenAPI generator extracts it into a $ref (and, in turn, code-generation tools consuming the spec would only define a single class for that shape). Generating the schema also lets us use advanced OpenAPI features like discriminator that would be impossible to encode by hand at our scale. We also generate extensions, such as per-case enum descriptions, for features that haven’t made it into the OpenAPI standard yet.
components: schemas: account: title: Account type: object additionalProperties: false properties: id: description: The Account identifier type: string name: description: The name of the Account type: string funding: description: The type of the Account type: string enum:
- deposits
- loan x-enum-descriptions:
- A depository account such as checking or savings
- A loan account created_at: description: When the Account was created type: string format: date-time required:
- id
- name
- funding
- created_at
OpenAPI also often gives you many ways to express the same thing (a rant post for another day). There are at least five ways to declare a nullable attribute in OpenAPI 3.1, each with trade-offs. Centralizing generation in one place lets us experiment with the format that’s best for our customers.
An expressive, up-to-date schema document is probably the single most valuable thing you can hand to an agent building against your API. But we get a lot more! We use ours to build every other downstream API-related artifact we publish: our API reference, language-specific SDKs, Postman collections, etc. There are lots of tools that can generate a nice documentation site from an OpenAPI schema but we hand-roll ours so we can use long-tail features or extensions in the spec like x-enum-descriptions.
This means that the documentation of our API can’t drift from its implementation. It might sound basic but not every API has this property and it’s maddening to encounter ones that don’t.
There are infinite ways to build and publish an API, but this is ours:
We’ve looked into open-sourcing our framework once or twice and stopped - it’s nice to have some coupling to the rest of our codebase (such as the associated_model method which ties it to our ORM). This code is well-suited for agents to write, though, so try pointing yours at this post and see where it gets you!
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。