-
Notifications
You must be signed in to change notification settings - Fork 332
[docs] api versioning #1958
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[docs] api versioning #1958
Changes from 4 commits
2e4dc86
748f483
5bf1c9b
8067b1b
0210409
d7feb3f
e6a9373
76e718c
6a8408d
068eaac
b98e8d9
e152f63
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Proposal for API versioning system. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,388 @@ | ||
| # Introduction | ||
|
|
||
| Since upgrades cannot happen instantaneously, we need release wire | ||
| backends and wire client apps that work together accross releases. | ||
|
|
||
| In the past, we have made sure that the api is changed in a way that | ||
| the backend can be newer than the client, and then releasing the | ||
| change on the backend first. This worked well on the cloud where we | ||
| had control, but fails a lot in the on-prem setting, and will get | ||
| worse with federation taking off: if we add a new end-point to the | ||
| API, the backend will still be able to handle older clients that | ||
| simply don't know about that end-point, but the client won't handle | ||
| old backends well, since it will try to call the end-point, and fail. | ||
|
|
||
| The new approach outlined here therefore supports API versions. Every | ||
| API version is only compatible with itself, but every node in the | ||
| network can support a *set of API versions*. A HTTP client can query | ||
| the set of supported versions from an HTTP server, and then pick one | ||
| that works for it. | ||
|
|
||
| In the following, we will refer to HTTP clients as "clients", no | ||
| matter whether it is a backend talking to another backend | ||
| (federation); and to HTTP servers as "server", no matter which API is | ||
| it serving (federation or app). This approach is intended to work for | ||
| both. | ||
|
|
||
|
|
||
| # Versions and servant routing tables | ||
|
|
||
| All routing tables for which a new version is born will be changed | ||
| into taking the version number as a parameter, which is added as a | ||
| prefix to every route: | ||
|
fisx marked this conversation as resolved.
|
||
|
|
||
| ```haskell | ||
| data Api (version :: Symbol) routes = Api | ||
|
fisx marked this conversation as resolved.
|
||
| { getUnqualifiedConversation :: | ||
| routes | ||
| :- version | ||
| :> Summary "Get a conversation by ID" | ||
| :> ZLocalUser | ||
| :> "conversations" | ||
| :> Capture "cnv" ConvId | ||
| :> Get '[Servant.JSON] Conversation, | ||
| getConversation :: | ||
| routes | ||
| :- version | ||
| :> Summary "Get a conversation by ID" | ||
| :> ZLocalUser | ||
| :> "conversations" | ||
| :> QualifiedCapture "cnv" ConvId | ||
| :> Get '[Servant.JSON] Conversation, | ||
| [...] | ||
| } | ||
| ``` | ||
|
|
||
| APIs of all the supported versions can be composed like this: | ||
|
|
||
| ```haskell | ||
| type ServantAPI = | ||
| ToServantApi (Api "v1") | ||
| :<|> ToServantApi (Api "v2") | ||
| :<|> ToServantApi (Api "v4") -- v3 is broken | ||
|
fisx marked this conversation as resolved.
|
||
| ``` | ||
|
|
||
|
|
||
| ## Changes between versions | ||
|
|
||
| The point of having versions is of course not that all of them look | ||
| exactly the same except for their prefix. The point is that there are | ||
| other things that change between versions. | ||
|
|
||
| There are essentially two categories of changes: | ||
|
|
||
| 1. **data**: request or response bodies, variable path segments, | ||
| possible headers or status codes (as in `UVerb` or `MultiVerb`), | ||
| etc. | ||
| 2. **structure**: literal path segments, verb, the version itself, ... | ||
|
|
||
|
|
||
| ## Changes in the data | ||
|
|
||
| If a data type in request, response, variable path segments, or anywhere else | ||
| changes, introduce a type family parameterized in the version. | ||
|
|
||
| ```haskell | ||
| [...] | ||
| getConversation :: | ||
| routes | ||
| :- version | ||
| :> Summary "Get a conversation by ID" | ||
| :> ZLocalUser | ||
| :> "conversations" | ||
| :> QualifiedCapture "cnv" ConvId | ||
| :> Get '[Servant.JSON] (ConversationV version), | ||
| [...] | ||
|
|
||
| type family ConversationV (version :: Symbol) :: * where | ||
| ConversationV "v1" = Conversation | ||
| ConversationV "v2" = Conversation | ||
| ConversationV "v4" = ConversationV4 | ||
|
fisx marked this conversation as resolved.
|
||
| ``` | ||
|
|
||
| Note that before version `"v4"`, nothing changed for this type, so | ||
| there was no need to introduce a new concrete data type. | ||
|
|
||
| If the last change of a data type is entirely phased out, the type | ||
| family turns constant and can be removed again. If you see this in | ||
| your code: | ||
|
|
||
| ```haskell | ||
| type family ConversationV (version :: Symbol) :: * where | ||
| ConversationV "v4" = ConversationV4 | ||
| ConversationV "v5" = ConversationV4 | ||
| ConversationV "v6" = ConversationV4 | ||
| ConversationV "v7" = ConversationV4 | ||
| ``` | ||
|
|
||
| You can remove 'ConversationV', rename `ConversationV4` to Conversation, | ||
| and use it in the routing table instead of `ConversationV` again, as | ||
| before `"v4"`. | ||
|
|
||
|
|
||
| ## Changing structure | ||
|
|
||
| Without loss of generality, we only consider additions and deletions | ||
| of routes in this section: if you want to change the path or verb of | ||
| an end-point, add a new path instead, and phase the old one out (now | ||
| or in some future version). | ||
|
|
||
| When end-points are present in some supported versions, but not in | ||
| others, their record fields in the servant routing type needs to be | ||
| present for all versions, but in some versions should behave as if it | ||
| weren't. | ||
|
|
||
| This is best solved by a new type alias: | ||
|
|
||
| ```haskell | ||
| type NotInThisVersion = Verb 'NOTINTHISVERSION '[] NoContent | ||
| ``` | ||
|
|
||
| Then we can write a type family that can crawl a `ServantAPI` (not the | ||
| record one, the one with `:<|>`) and drop all the routes marked as not | ||
| existing. | ||
|
fisx marked this conversation as resolved.
Outdated
|
||
|
|
||
| This will save us the trouble of writing lots of instances for | ||
| `NotInThisVersion` (server, swagger, client, ...), and yield exactly | ||
| the desired result: | ||
|
|
||
| ```haskell | ||
| type ServantAPI = | ||
| DropNotInThisVersion | ||
| ( ToServantApi (Api "v1") | ||
| :<|> ToServantApi (Api "v2") | ||
| :<|> ToServantApi (Api "v4") -- v3 is broken | ||
| ) | ||
| ``` | ||
|
|
||
|
|
||
| ## Adoption of versioned APIs | ||
|
|
||
| When API versions are introduced to a code base that has a routing | ||
| table without versions, the question arises what to do with old | ||
| clients or servers talking to new ones. | ||
|
|
||
| We define a middleware that | ||
|
|
||
| (1) maps requests without version prefix in the path to ones that | ||
| have version `"v0"`. | ||
|
|
||
| (2) responds with a specific type of error if an unsupported version | ||
| is requested (so the client can re-negotiate a new version to | ||
| speak after an upgrade, see below). | ||
|
|
||
|
|
||
| ## Version handshake | ||
|
|
||
| Client and server need to agree on a version to use. The server | ||
| provides two (kinds of) end-points for that. | ||
|
|
||
| ``` | ||
| GET /api-versions | ||
|
fisx marked this conversation as resolved.
|
||
| => { "supported": [1, 2, 45, 119] } | ||
|
|
||
| GET /v*/api-docs | ||
| => <swagger for this particular version> | ||
| ``` | ||
|
|
||
| The client developer can pull the swagger docs of a new version and | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You mean: there is an automated diffing too that the backend provides. 😉 Even better: there is a compare function similar to |
||
| diff it against the one they already support, and work their way | ||
| through the changes (see below). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You mean: And the changes automatically generate a new stub client
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think so, yes. |
||
|
|
||
| The client will call `GET /api-versions` and pick any number in the | ||
| intersection of the versions it supports and the ones the server | ||
| supports (usually the largest). | ||
|
|
||
| *Corner case:* if we want to distinguish between backend-to-backend | ||
| and client-to-backend, we can do that in path suffixes (`GET | ||
| /api-versions/{client,federation}` etc.). | ||
|
|
||
|
|
||
| ### No shared api version | ||
|
|
||
| If the intersection set is empty, the client has no way of talking to | ||
| the server. It needs to politely fail with an error message about | ||
| upgrading, downgrading, or talking to another server. | ||
|
|
||
| This should only happen if the distance between last upgrade on client | ||
| and server exceeds the agreed-upon limits (eg., 6 months). | ||
|
|
||
|
|
||
| ### Update detection and version re-negotation | ||
|
|
||
| If the server is upgraded and some old supported versions are phased | ||
| out, the client may be caught by surprise. | ||
|
|
||
| The clients should therefore frequently (every 24h?) refetch | ||
|
fisx marked this conversation as resolved.
Outdated
|
||
| /api-versions and possibly raise warnings if the intersection set gets | ||
| too small. | ||
|
|
||
| The client could also avoid that extra work and instead have a | ||
| catch-all that will handle the specific response from the versioning | ||
| middleware (see above), re-fetch `/api-versions` and try again. | ||
|
|
||
|
|
||
| ## Strongly typed versions | ||
|
|
||
| If we make version an ADT `ApiVersion`, we can remove old versions | ||
| from it in one place and have the compiler guide us through all the | ||
| places where we need to remove it. | ||
|
|
||
| There are at least two ways to implement this: | ||
|
|
||
| 1. Add a few extra servant instances for `(v :: ApiVersion) :> route`. | ||
| 2. Define a type family `Versions` that maps `V*` to `"v*"`, and write | ||
| `Versions version :>` in the routing type instead of `"v1"`. | ||
|
|
||
| 2 seems a lot less work to write, read, and understand. | ||
|
|
||
|
|
||
| ## Data migration (aka data marshalling) | ||
|
|
||
| If the shape of an end-point changes between versions (if a data type | ||
| in the routing table becomes a type family), it is often possible to | ||
| write marshalling functions that translate a value from an older | ||
| version into one of a newer version or vice versa. | ||
|
|
||
| These functions are called marshalling functions and are useful to | ||
| define separately to keep the application logic clean. | ||
|
|
||
| For certain changes to a data type used in an API, marshalling is | ||
| straight-forward in both directions. The most common example is | ||
| adding an optional attribute to a JSON object: | ||
|
|
||
| - *backward migration*: remove the new attribute. | ||
| - *forward migration*: set the new attribute to `null`. | ||
|
|
||
| (This is what wire has traditionally done to accomplish client | ||
| backwards compatibility without any API versioning.) | ||
|
|
||
| If a mandatory attribute is added in a newer version, there may be a | ||
| plausible default value that can be used in the forward migration | ||
| (backward migration would still remove the field). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Older clients won't be able to read the field because they are unaware of it.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not about an additional field that clients can not yet read, but an additional fiend that the backend expects FROM the client. If the client did not specify that field, the backend will assume a default value. |
||
|
|
||
| In other cases, whether there is an automatic migration depends on the | ||
| use case and the semantics. | ||
|
|
||
| It may even be impossible to marshal either in one or in both | ||
| directions. In this case, you have 3 options: | ||
|
|
||
| 1. abandon compatibility; | ||
| 2. rethink your new version and craft it in a way that two-way | ||
| marshalling is possible; | ||
| 3. make the application work around the gap, eg. by gracefully | ||
| refusing to offer video conferencing in a client if it is not | ||
| supported on the server yet. | ||
|
|
||
|
|
||
| ## Writing client code | ||
|
|
||
| If you write all code by hand and don't generate anything from the | ||
| swagger docs, just look at the swagger diff for every new version and | ||
| take it from there. | ||
|
|
||
| If you generate, say, typescript or kotlin or swift from swagger: | ||
|
|
||
| 0. have a generated source module `Gen.ts`, plus a source module with | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We will version a library per version of the API achieving something similar to what you describe I believe
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That may end you up with more dead code in the executable, but that may not be a problem. Implementation detail! |
||
| manually written code `Man.ts`. Re-export everything from `Gen.ts` | ||
| in `Man.ts`, and only import `Man.ts` in any modules that contain | ||
| application logic. | ||
|
|
||
| 1. look at the diff of the swagger of the last supported and the new | ||
| versions. | ||
|
|
||
| 2. copy all functions for routes that have changed from `Gen.ts` to | ||
| `Man.ts`. these are speaking an old api version and won't need to | ||
| be re-generated any more. work the last version supported by this | ||
| function into the name somehow (eg., suffix `"_v23"`). | ||
|
|
||
| 3. for every function that moved to `Man.ts` in this way, write a | ||
| function *without* the version suffix. It somehow knows the api | ||
| version of the server that it talks to (function parameter, app | ||
| config, dosn't matter), and decides based on that whether to call | ||
| the deprecated function with the `"_v23"` suffix or the one from | ||
| `Gen.ts`. If the old one is called, it may have to do some | ||
| marshalling of request and response (see above). | ||
|
|
||
| It will happen that a new client will not be able to accomplish | ||
| something with an old API (example: if video calling is introduced in | ||
| `"v12"`, you can't emulate `POST /video-call` when talking to a `"v9"` | ||
| server. In these cases, the function in `Man.ts` must raise a "server | ||
| too old" exception, and gracefully shut down the new functionality. | ||
|
|
||
|
|
||
| ## Concerns and design alternatives | ||
|
|
||
| ### Why not version every end-point separately? | ||
|
|
||
| Yes, that would work in principle. On the backend, it would make the | ||
| entire routing table smaller (no need to concatenate the same | ||
| end-point for many versions), which may result in shorter compile | ||
| times. On the clients, with a new API version it would be | ||
| straight-forward to see which end-points need to be worked on, and | ||
| which remain unchanged. | ||
|
|
||
| On the other hand, the routing table size may not be an issue, and if | ||
| it is there are solutions (introduce a CCP switch to compile only the | ||
| most recent API version that you're working on); and the client | ||
| process is already quite straight-forward with the approach outlined | ||
| above via diffing the swagger docs between most recent version and | ||
| predecessor. | ||
|
|
||
| Plus, if the entire API has one version, you get a few advantages: | ||
|
|
||
| 1. The fact that clients are forced to commit to a concrete API | ||
| version for all end-points when talking to the backend reduces | ||
| testing complexity. If there is a mapping of end-points to | ||
| versions, the behavior of interacting parties is much less | ||
| restricted, and versions that have not been tested against each | ||
| other may be used together. (This can be avoided, but it's less | ||
| obvious how to get it right, and testing complexity will likely be | ||
| worse.) | ||
|
|
||
| 2. The "one version" approach makes it obvious which end-points are in | ||
| the most recent API at any given point in time. The "one version | ||
| per end-point" approach would either yeild a noisy union of all | ||
| supported versions, or there would have to be a mechanism for | ||
| reconstructing something close to what we get for free otherwise. | ||
|
|
||
| 3. The backend code is a good combinatin of concise and type-safe in | ||
|
fisx marked this conversation as resolved.
Outdated
|
||
| the "one version" approach. If every end-point had its own | ||
| version, the routing table entry would either have to accept a | ||
| variable path segment for the version, and fail at run-time if the | ||
| version is not supported, or you would have to add one handler per | ||
| supported version (even if in the case where all versions call the | ||
| same handler function with slightly different parameters). | ||
|
|
||
|
|
||
| ### Syntactical vs. behavioral changes | ||
|
|
||
| It is quite common that behavior of end-points changes together with | ||
| the syntax, or even without a change in the syntax. | ||
|
|
||
| This is not a fundamental problem: since the handler can be called | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens to old frontend clients if we force a change of version when the behavior changes, but the data doesn't? E.g. there's an endpoint to upload assets, that are stored in a somehow generic bucket (V1). Old clients that only talk V1 could, in theory, still use the endpoint to send images. The fact that they are not aware of the new behavior does not prevent them from sending images; both V1 and V2-aware backends and clients can process those images successfully. However, since the client doesn't speak V2, it will be prevented from talking to V2 backends. This sounds to me like an artificial limitation. Probably in cases like this we should consider changing the behavior of the V1 endpoint to behave like V2, keep the V1, and at the same time introduce V2. Basically backporting the V2 behavior to V1, since they are compatible data-wise. This might increase complexity on the backend implementation (does it?), but has the advantage of not forcing a client update for every user. Would this be a valid strategy?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would recommend against retroactively changing APIs of old versions. That somewhat defeats the purpose of using versioning, breaks the whole model and can introduce hard-to-diagnose issues. I don't quite understand your example here. If V2 is the same as V1, but just implemented differently, it should just not cause a version bump, so V1=V2, and clients have no compatibility issues. If V2 has strictly more features than V1, the backend can support both, so V1-clients can still talk to {V1,V2}-backends.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks @pcapriotti, I agree! @marcoconti83 let me know if this just raises further questions. :)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My example was a bit convoluted, but you @pcapriotti got what I meant when you said:
... (adding) thanks to the backend still honoring the V1 API, even if the internals of the backend behavior is changed, if that change is not relevant for the API. I'll rewrite the example: With V1, we store assets without any user information (who uploaded them). With V2, we introduce a new feature to keep track of which user uploaded which asset, and allow users to delete them. We somehow run a migration script once to assign all previously unassigned assets to a user (doesn't matter how) and from now on, we assume all assets are assigned to a user. We now have two options:
I'm bringing up this example because we had to do such hybrid systems in the past to support a transition between one behavior and another, and I don't want the versioning system to preclude this possibility.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks @marcoconti83 for clarifying the example, now I understand better what you mean. I think option two should work without problems. In practice, what I imagine would happen in the server is that V1 would be unmodified at the level of the API and the corresponding types, but its implementation would share code with V2, implementing the new user-tracking functionality. But I see your point now: in some sense, we are indeed retroactively changing V1 when we add V2, since V1 is now implemented the same as V2. However, this is only in the implementation, and the API contract of V1 is still identical to before. How to make sure that these contracts are indeed preserved over time is another matter altogether, and not discussed in this proposal. We've had some previous discussions around a compatibility testing framework, but it was focused on the server-to-server case, and based on a slightly different approach to versioning: https://wearezeta.atlassian.net/wiki/spaces/CORE/pages/529170506/Compatibility+testing+for+different+backend+versions+in+federation. I imagine many of the ideas still apply, though. |
||
| with the version as a type parameter, there is no reason why it | ||
| shouldn't change behavior with or without changing the syntax. In | ||
| each such case, it needs to be decided whether the difference is | ||
| significant enough to justify a new API version. | ||
|
|
||
| At the very least though it should result in diverging swagger docs | ||
| that explains those differences. | ||
|
|
||
|
|
||
| ### Client capabilities | ||
|
|
||
| Wire supports client capabilities to decide whether a client should be | ||
| allowed to use certain parts of the API. | ||
|
fisx marked this conversation as resolved.
|
||
|
|
||
| This is another alternative to API versions, and it is in some ways | ||
| more straight-forward to decide who to interpret capability sets. But | ||
| this approach has its own problems: Most importantly, the number of | ||
| supported capability sets grows quadratically (not in practice, | ||
| because historically clients will only ever support a small part of | ||
| all possible combinations of capabilities, but that makes thigns | ||
| worse: it makes the system more complex, and then doesn't use that | ||
| complexity for anything). | ||
|
|
||
| Therefore, the capabilities we're using in the wire code base should | ||
| be gracefully phased out and replaced by API versions. | ||
Uh oh!
There was an error while loading. Please reload this page.