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

How to add versioning to my API using huma? #377

Open
aleksicmarija opened this issue Apr 12, 2024 · 7 comments
Open

How to add versioning to my API using huma? #377

aleksicmarija opened this issue Apr 12, 2024 · 7 comments
Labels
question Further information is requested

Comments

@aleksicmarija
Copy link

aleksicmarija commented Apr 12, 2024

I found some configurations for prefixes, but it seems that's not it. Is there a way to register a route without a prefix and huma mechanism will choose between versions if we implement them (I'm aware that it needs to be configured but I'm searching for a best way)?
I have api versioning but i would like to add it into huma so i can have all versions in my API docs.

Some example would be amazing.

@danielgtaylor danielgtaylor added the question Further information is requested label Apr 12, 2024
@danielgtaylor
Copy link
Owner

@aleksicmarija there isn't any sort of API resource versioning functionality built-in, since there are many different ways to accomplish this and it's not always easy to model via OpenAPI. Currently the easiest way to do this is via separate operation paths (e.g. version in the URL path) so each "version" is a distinct operation with its own input/output models. This versioning strategy doesn't need any special support in Huma, but maybe some utility functions or a utility package could be written to make it nicer to set up.

Supporting something like one operation with different inputs/outputs based on a version header, or looking up the user's selected version, or some other mechanism is harder to do and I'm not sure how to model it. I'm open to ideas on this one - how would you like it to work?

Also worth looking into is OpenAPI Project Moonwalk, their current special interest group working on OpenAPI v4 which enables multiple operations per HTTP method, differentiated by things like query or header values.

@spa5k
Copy link

spa5k commented Apr 12, 2024

We can use dotnet core on how they implement this functionality. Generally it's based on either headers (x-api-version) or the generic prefix like /v1/

@aleksicmarija
Copy link
Author

aleksicmarija commented Apr 14, 2024

Well, my idea is that instead of a single handler function, the register function could receive a map with uint keys where the values are handlers. Then, based on the version extracted from the route /api/v{version}, it would determine which route should be called. I'm not sure if something like this is possible?

This is how we are doing it now without Huma: we wrap our handler, where firstly we extract the version from the route, and then we get a handler for that version.

@ssoroka
Copy link
Contributor

ssoroka commented Apr 15, 2024

This might belong in a package that extends huma rather than in huma itself, for the reasons Daniel mentioned.

/v1 /v2 etc routes aren't a great ideal as far as versioning goes. it's a very inflexible system and hard to upgrade all endpoints in sync.

Personally I'm a fan of Stripe's date-based api versioning, and––depending on the project––might want the ability to upgrade and downgrade request and responses based on the requested version vs the current version. This involves writing request/response upgraders and downgraders, rather than maintaining separate versions of each api endpoint forever.

Point is, I agree versioning could be complicated and should probably be a separate extension. Not sure what huma would have to expose to support this, but what I've done before is output multiple openapi schemas, one per version. When the UI selects a different version, it's a completely different openapi schema.

jzillmann added a commit to jzillmann/go-experiments that referenced this issue Apr 17, 2024
- Route implementation seems easier
  - binding and input validation already taken care of
  - Open API spec, Docs browser, etc (http://localhost:8080/docs)
- Removed the `<version>/API` prefix
  - danielgtaylor/huma#377 (comment) seems like a good take on why this versioning doesn't make much sense
  - If there is need for a public API it might make sense to introduce it explicitly. E.g. have `internal/...` for our web app (free to change) and `api/...` for external tools (versioned and backwards compatible)
@nickajacks1
Copy link
Contributor

Supporting something like one operation with different inputs/outputs based on a version header, or looking up the user's selected version, or some other mechanism is harder to do and I'm not sure how to model it. I'm open to ideas on this one - how would you like it to work?

One method that my colleagues have used is using the Content-Type along with the request's Accept header. For example:
Accept: application/json; version=1

It's conceptually the same idea as a vendor specific content type, eg application/vnd.myapi.v2+json
To handle the former, you need robust content negotiation, which there isn't a huge amount of support for in the Go world yet. Not many Go http libraries/frameworks will parse media-type parameters. c.f. Express's res.format which takes parameters into account.

Modelling content type-based versioning in an openapi document is fairly easy since there is already a way to specify responses for multiple content types.
Having a dedicated header to specify the versioning is easier implementation-wise, but it also makes it a little harder to map a payload type to the corresponding API version.

@danielgtaylor
Copy link
Owner

Here is a very basic example of how you could support types that can be downgraded and allow clients to select a version via the Accept header:

https://go.dev/play/p/GYj_GyszZWN

First, define the current version and the downgrade transforms:

type MyType struct {
	Name string            `json:"name"`
	Tags map[string]string `json:"tags"`
}

func (m MyType) V2() any {
	// In v2, tags were just a list of strings.
	tags := []string{}
	for k, v := range m.Tags {
		tags = append(tags, fmt.Sprintf("%s=%s", k, v))
	}
	return map[string]any{
		"name": m.Name,
		"tags": tags,
	}
}

func (m MyType) V1() any {
	// In v1, the name was split into first and last. Tags did not exist yet.
	parts := strings.Split(m.Name, " ")
	return map[string]any{
		"first": strings.Join(parts[:len(parts)-1], " "),
		"last":  parts[len(parts)-1],
	}
}

Next, write a Huma transformer to convert types which have such methods based on the incoming accept header. This is hacky - in a real world service you want to properly parse the header.

func versionedTransform(ctx huma.Context, status string, v any) (any, error) {
	accept := ctx.Header("Accept")

	if strings.Contains(accept, "version=2") {
		if m, ok := v.(interface{ V2() any }); ok {
			return m.V2(), nil
		}
		return nil, fmt.Errorf("unsupported type: %T", v)
	} else if strings.Contains(accept, "version=1") {
		if m, ok := v.(interface{ V1() any }); ok {
			return m.V1(), nil
		}
		return nil, fmt.Errorf("unsupported type: %T", v)
	}
	return v, nil
}

Lastly, set the transform on the config before creating the API instance, then make an operation that returns your type from the body. You can run the code in the playground link to see the results:

========== Latest version ==========
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json
Link: </schemas/MyType.json>; rel="describedBy"

{
  "$schema": "https://example.com/schemas/MyType.json",
  "name": "John Doe",
  "tags": {
    "foo": "bar"
  }
}

========== Version 2 ==========
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json

{
  "name": "John Doe",
  "tags": [
    "foo=bar"
  ]
}

========== Version 1 ==========
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json

{
  "first": "John",
  "last": "Doe"
}

Some caveats:

  • You probably want to properly parse the Accept header.
  • Only the latest version is documented in the generated API docs. If you want to document all versions you may need a different approach or have types provide a custom schema.
  • This doesn't mix well with the schema link transformer that adds $schema into the responses, because returning map[string]any destroys any specific type information it uses to link to a type.

Anyway, someone could probably build on this idea to provide a utility library for versioning on top of Huma.

@nickajacks1
Copy link
Contributor

I found that simply defining different huma.MediaTypes for a Response's Content almost works with media-type parameters. Here's what I mean:

	Responses: map[string]*huma.Response{
		"200": {
			Content: map[string]*huma.MediaType{
				"application/json; version=1": {
					// stuff
				},
				"application/json; version=2": {
					// other stuff
				},
			},
		},
	}

From my understanding, the above would in theory place everything in the generated openapi. However, the parsing of Accept headers doesn't handle parameters (though it could). I actually added media-type paramter parsing to fasthttp because a framework I like uses fasthttp and I wanted better content negotiation. SelectQValue and SelectQValueFast are almost there, but as the name suggests, they only get the qvalue. For those that don't actually care about allocation, the standard library already provides a way to parse media types with parameters.

Using vendor-specific content types is definitely simpler implementation-wise though because of the sheer lack of demand for media-type parameter parsing :( . I'm not a huge fan of vendor specific content types because it's harder for me to remember which to use when sending a request on the command line.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

5 participants