Skip to content
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

[HTTP] Versioned API router designs #151596

Merged
merged 13 commits into from
Feb 22, 2023

Conversation

jloleysens
Copy link
Contributor

@jloleysens jloleysens commented Feb 20, 2023

Summary

This PR contains the initial designs for our versioned router API. This contribution contains only types, any implementation will come in later PRs.

Previous PR #149943

@jloleysens jloleysens added Feature:http Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc release_note:skip Skip the PR/issue when compiling release notes auto-backport Deprecated - use backport:version if exact versions are needed v8.8.0 labels Feb 20, 2023
@jloleysens jloleysens self-assigned this Feb 20, 2023
@jloleysens
Copy link
Contributor Author

jloleysens commented Feb 20, 2023

A few additional points for discussion

  1. How could/should we expose this to plugins?

If we want to give plugins the easiest access to the versioning toolkit, we should implement it such that the createVersionedRouter is wrapped behind a Core-exposed interface. Perhaps something like:

// ...really haven't given this much thought:
const versionedRouter = coreSetup.http.createVersionedRouter();
versionedRouter.post( ... );
  1. Should we consider passing version directly to a route handler?

Note: consumers can implement this in the current design by doing something like:

const mySingleHandler = (version: '1' | '2', ctx, req, res) => { /* infer body type, switch/if-else and do stuff */ }

const versionedRoute = versionedRouter.post({ path: '/api/my-api/foo' });

versionedRoute
  .addVersion({ version: '1', validate: { /* some validation */ } }, async (ctx, req, res) => mySingleHandler('1', ctx, req, res))
  .addVersion({ version: '2', validate: { /* some other validation */ } }, async (ctx, req, res) => mySingleHandler('2', ctx, req, res))

...but a dedicated API designed to do this may be simpler to use.

In the current design we require a new handler for each version of a route. In certain cases it may be preferable for consumers to receive the version value via ctx. Design-wise: primary difference is we will require a single handler for all versions. The question is, how common is this case?

With a single handler things become a bit less ergonomic: consumers will need to switch/map this onto their appropriate handler and infer types of body, query and params.

  1. I decided to keep the .get, .put etc. interface to keep things similar to the current IRouter

Taking @rudolf's point about familiarity of APIs further, the current design exposes a VersionedRouter with the same .get style registrars.

* Side Public License, v 1.
*/

import { schema } from '@kbn/config-schema';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this example as it is easier to follow than the doc comment version, we can remove this if we feel it is not helpful.

@@ -0,0 +1,13 @@
# @kbn/core-version-http-server
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming things, naming things... What to name things? Happy to change this. I chose to put this in a package outside core/http and follow the naming convention of -server. My guess is we will have the same thing later, but -browser.

Copy link
Contributor

@rudolf rudolf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left some nits with thoughts on how we could future-proof the API even more, but these are all compatible changes we can make in the future so it doesn't have to block the PR

* Options for a versioned route. Probably needs a lot more options like sunsetting
* of an endpoint etc.
*/
export interface AddVersionOpts<P, Q, B> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is probably not something that would often be used, but we could probably extend RouteConfigOptions to future proof the API so that e.g. changing the max body size or accepts content types doesn't require a new "parent" Router but can be done in a new version https://github.com/elastic/kibana/blob/main/packages/core/http/core-http-server/src/router/route.ts#L102

Comment on lines 35 to 57
* ```ts
* const versionedRoute = versionedRouter
* .post({
* path: '/api/my-app/foo',
* options: { timeout: { payload: 60000 } },
* })
* // First version of the API, accepts { foo: string } in the body
* .addVersion(
* { version: '1', validate: { body: schema.object({ foo: schema.string() }) } },
* async (ctx, req, res) => {
* await ctx.fooService.create(req.body.foo);
* return res.ok({ body: { foo: req.body.foo } });
* }
* )
* // Second version of the API, accepts { fooName: string } in the body
* .addVersion(
* { version: '2', validate: { body: schema.object({ fooName: schema.string() }) } },
* async (ctx, req, res) => {
* await ctx.fooService.create(req.body.fooName);
* return res.ok({ body: { fooName: req.body.fooName } });
* }
* );
* ```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: one of the cons of structuring an API by path instead of by version (our previous discussion) is that it becomes more awkward to make a breaking change to a path. So you have to create a new versionedRouter and it's no longer clear that it shares a "version history" with other paths/controllers.

So I wondered if we should allow a path to be specified in a version too. E.g. changing '/api/my-app/foo' -> '/api/my-app/foobar'

const versionedRoute = versionedRouter
.post({
  path: '/api/my-app/foo',
  options: { timeout: { payload: 60000 } },
})
// First version of the API, accepts { foo: string } in the body
.addVersion(
  { version: '1', validate: { body: schema.object({ foo: schema.string() }) } },
  async (ctx, req, res) => {
    await ctx.fooService.create(req.body.foo);
    return res.ok({ body: { foo: req.body.foo } });
  }
)
// Second version of the API, accepts { fooName: string } in the body and new path
.addVersion(
  { 
    version: '2',
    path: '/api/my-app/foobar', // <-- Version 2 makes a breaking change to the path
    validate: { body: schema.object({ fooName: schema.string() }) }
  },
  async (ctx, req, res) => {
    await ctx.fooService.create(req.body.fooName);
    return res.ok({ body: { fooName: req.body.fooName } });
  }
);

This should not be a very common use case but it does happen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great point. I think it could definitely happen with parameters added/removed/changed in the path!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll incoroporate your changes, but leave this comment open for others!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A "breaking change to a path" is basicaly a new path so not sure it is a breaking change for the client. ('/api/my-app/foobar' is a new "v1" route). The example of JL(in example.ts) where he changes the parameter name from "name" to "id" is maybe an exception.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point @sebelga , I realise that example did not capture the nature of the issue correctly. That would be a change that is not visible to consumers of the API. I'll update to capture it correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The breaking change in the example is in the body where we change foo to fooName. I was just hoping to show that you can change name of params if you wanted to.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'/api/my-app/foobar' is a new "v1" route

It could be modeled that way but doesn't have to be. In e.g. alerting we changed our terminology so what was previously called alerts now became rules and actions became alerts. To capture this change in domain language they might want to release a new version of the API where many paths would be different. Having two completely different endpoints both use alerts with a different meaning would be confusing so here the new version helps users navigate the change.

@sebelga
Copy link
Contributor

sebelga commented Feb 20, 2023

Can you confirm I understand correctly the mechanism

  • We declare version "1" on first release
  • New version is released (2) but we don't need any data change so we don't declare any new route
  • For the next version (3) we need a change so we declare a version "3" on the router? We skip version "2" declaration is that correct?

Copy link
Contributor

@TinaHeiligers TinaHeiligers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm missing something subtle in allowing path params to change. I left some comments/questions to clarify.

.addVersion(
{
version: '2',
path: '/api/my-app/foo/{id?}', // Update the path to something new
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path: '/api/my-app/foo/{id?}', // Update the path to something new
If routers also have to be backward compatible, then would it be better to accept both optional arguments? For example:

Version 2's path could be:
path: '/api/my-app/foo/{nameorid?}',
where nameorid = name | id | undefined

If someone still uses the "old" path with a name as the query param, and the route has changed only to accept an optional id, would we not consider that a breaking change and, therefore, not permitted?

My understanding is that we want public HTTP API's to be as stable as possible and any iterations on the route declaration should still work with older versions. Maybe I'm missing some subtlety here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone still uses the "old" path with a name as the query param, and the route has changed only to accept an optional id, would we not consider that a breaking change and, therefore, not permitted?

Yes, that is a good point. I've updated the example to reflect this as a breaking change.

My understanding is that we want public HTTP API's to be as stable as possible and any iterations on the route declaration should still work with older versions. Maybe I'm missing some subtlety here.

Also a good point :). My reasoning was that we don't want breaking changes such that a later version removes an earlier version. But I think there are nuanced cases of breaking changes that we should consider allowing if they bring code/functionality to a place that is more maintainable.

CC @rudolf

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the example I shared in #151596 (comment) is helpful.

We want any given version of the API to be stable, but we don't want to hold back teams. If they deem it necessary / beneficial to make a breaking change they should create a new version for that so that they're unblocked.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make a breaking change they should create a new version

Creating a new version for a new path makes me far more comfortable!

@jloleysens
Copy link
Contributor Author

jloleysens commented Feb 21, 2023

New version is released (2) but we don't need any data change so we don't declare any new route

Would it be possible get a code example of a change like this? If I understand correctly: the intention is to not release new versions (only patch versions) for non-breaking changes.

[UPDATE]
I've updated the code example, so hopefully it is a bit clearer now.

@jloleysens
Copy link
Contributor Author

CC @sebelga (forgot to add a ping)

.addVersion(
{
version: '2',
path: '/api/my-app/foo/{id?}/{name?}', // Update "fooId" => "id", this is not a breaking change!
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it not a breaking change? Our code won't be able to access params.fooId, the value will now be under params.id right?

Copy link
Contributor

@rudolf rudolf Feb 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

breaking changes refer to changes which break clients, we can always adapt our code to deal with "internal" breaking changes like this. So a client will still construct exactly the same query whether we map that path variable to id or fooId

@sebelga
Copy link
Contributor

sebelga commented Feb 21, 2023

I'm still confused on what a route version maps to. Can you confirm that we won't declare them incrementally ("v1", "v2", "v3"...) but based on a release cadence (we might have "v1", "v7" route version but not the intermediary ones) ?

@rudolf
Copy link
Contributor

rudolf commented Feb 21, 2023

I'm still confused on what a route version maps to. Can you confirm that we won't declare them incrementally ("v1", "v2", "v3"...) but based on a release cadence (we might have "v1", "v7" route version but not the intermediary ones) ?

A route version is introduced by a plugin team any time they think it's necessary to make a breaking change. For public APIs this cadence should be extremely slow, we can't remove APIs in < 18 months. But for internal APIs we should feel free to introduce a new version as often as it helps remove technical debt or support new features.

But api versions don't ever correspond to stack release versions.

@@ -21,26 +21,62 @@ const versionedRouter = vtk.createVersionedRouter({ router });
// @ts-ignore unused variable
const versionedRoute = versionedRouter
.post({
path: '/api/my-app/foo/{name?}',
path: '/api/my-app/foo/{fooId?}/{name?}', // <= design mistake, {name?} should be a query parameter, but it has been released...
Copy link
Contributor

@TinaHeiligers TinaHeiligers Feb 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside: What would the path be if fooId isn't defined? /api/my-app/foo/undefined/name or /api/my-app/foo//name or something else?

@TinaHeiligers
Copy link
Contributor

api versions don't ever correspond to stack release versions

Decoupling api versions from stack release versions is what's tripping a lot of folks up, even though that was communicated early on. Maybe we didn't make the message clear enough or there are too many folks who don't have all the history. @rudolf is there some way we can communicate this more widely?

Copy link
Contributor

@TinaHeiligers TinaHeiligers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could iterate on the design forever but we have to make a start somewhere. The design looks good enough to me to start sharing.
Great work!

@TinaHeiligers TinaHeiligers self-requested a review February 21, 2023 22:39
Copy link
Contributor

@TinaHeiligers TinaHeiligers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@jloleysens jloleysens marked this pull request as ready for review February 22, 2023 11:32
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-core (Team:Core)

@jloleysens
Copy link
Contributor Author

Thanks for the early review @sebelga @rudolf @TinaHeiligers . I am going to merge this as our first iteration, we can continue updating it in following PRs.

@jloleysens jloleysens enabled auto-merge (squash) February 22, 2023 11:33
@kibana-ci
Copy link
Collaborator

💚 Build Succeeded

Metrics [docs]

✅ unchanged

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

cc @jloleysens

@jloleysens jloleysens merged commit acf7d01 into elastic:main Feb 22, 2023
@kibanamachine kibanamachine added the backport:skip This commit does not require backporting label Feb 22, 2023
@sebelga
Copy link
Contributor

sebelga commented Feb 22, 2023

A route version is introduced by a plugin team any time they think it's necessary to make a breaking change. For public APIs this cadence should be extremely slow, we can't remove APIs in < 18 months. But for internal APIs we should feel free to introduce a new version as often as it helps remove technical debt or support new features.

Cool got it 👍

The piece of the puzzle that I was missing was that each browser client (per plugin) hardcodes their latest version when bundling. And that version is unique for each plugin.

@jloleysens jloleysens deleted the versioned-api-router-designs branch February 22, 2023 15:53
jloleysens added a commit that referenced this pull request Mar 28, 2023
## Summary

Implements the designs from
#151596

* Move `packages/versioning/*` into `packages/core/http` to follow
existing structure more closely
* Implements the first iteration of the versioned router as a
wrapper/layer around the existing router
* Adds some integration tests
* Future work needed! Once we have a the versioned spec we should
implement it in this wrapper layer
* Validation is a little bit tricky because of when the
`CoreKibanaResponse` object is instantiated, the approach taken here is
to replace body, params, query on the route-level's request object

Closes #149286

---------

Co-authored-by: kibanamachine <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
auto-backport Deprecated - use backport:version if exact versions are needed backport:skip This commit does not require backporting discuss Feature:http release_note:skip Skip the PR/issue when compiling release notes Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v8.8.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants