From f419fb767d5905d47baa03a79ea37ef9745d900a Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 21 Mar 2025 23:34:28 +0100 Subject: [PATCH 1/2] feat: support docs on separate adapter This allows exposing the spec and docs on a separate adapter, potentially under a separate path and/or middleware stacks. E.g.: ``` opts := huma.DefaultConfig("foo", "0.0.1") opts.DocsAdapter = humachi.NewAdapter(internalRouter) opts.CreateHooks = []func(huma.Config) huma.Config{ huma.DefaultSchemaLinkHook(huma.DefaultSchemaRefPrefix, internalPrefix), // assuming internalRouter above is mounted under internalPrefix } adapter := humachi.NewAdapter(mainRouter) huma.NewAPI(opts, adapter) ``` --- adapters/humachi/humachi_test.go | 3 ++- api.go | 35 ++++++++++++++++++++------------ defaults.go | 35 ++++++++++++++++++-------------- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/adapters/humachi/humachi_test.go b/adapters/humachi/humachi_test.go index 9721c669..cc82a531 100644 --- a/adapters/humachi/humachi_test.go +++ b/adapters/humachi/humachi_test.go @@ -350,7 +350,8 @@ func TestChiRouterPrefix(t *testing.T) { // The docs HTML should point to the full URL including base path. resp = tapi.Get("/api/docs") assert.Equal(t, http.StatusOK, resp.Code) - assert.Contains(t, resp.Body.String(), "/api/openapi.yaml") + // The openapi.yaml should be a relative path. + assert.Contains(t, resp.Body.String(), `apiDescriptionUrl="openapi.yaml"`) } // func BenchmarkHumaV1Chi(t *testing.B) { diff --git a/api.go b/api.go index e99bc439..ed00a85c 100644 --- a/api.go +++ b/api.go @@ -10,7 +10,6 @@ import ( "mime/multipart" "net/http" "net/url" - "path" "reflect" "regexp" "strings" @@ -181,6 +180,15 @@ type Config struct { // blank and attach it directly to the router or adapter. DocsPath string + // DocsAdapter is an optional [Adapter] that will be used to serve the API + // documentation. If unset, the main adapter will be used. + // Setting this allows you to use a different router or middleware stack for + // the documentation. + // Note that if this [Adapter] has a different path than the main one, the + // default CreateHook set in [DefaultConfig] needs to be modified to account + // for it. + DocsAdapter Adapter + // SchemasPath is the path to the API schemas. If set to `/schemas` it will // allow clients to get `/schemas/{schema}` to view the schema in a browser // or for use in editors like VSCode to provide autocomplete & validation. @@ -362,7 +370,7 @@ func getAPIPrefix(oapi *OpenAPI) string { // config := huma.DefaultConfig("Example API", "1.0.0") // api := huma.NewAPI(config, adapter) func NewAPI(config Config, a Adapter) API { - for i := 0; i < len(config.CreateHooks); i++ { + for i := range config.CreateHooks { config = config.CreateHooks[i](config) } @@ -400,9 +408,14 @@ func NewAPI(config Config, a Adapter) API { newAPI.formatKeys = append(newAPI.formatKeys, k) } + docsAdapter := newAPI.adapter + if config.DocsAdapter != nil { + docsAdapter = config.DocsAdapter + } + if config.OpenAPIPath != "" { var specJSON []byte - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.OpenAPIPath + ".json", }, func(ctx Context) { @@ -413,7 +426,7 @@ func NewAPI(config Config, a Adapter) API { ctx.BodyWriter().Write(specJSON) }) var specJSON30 []byte - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.OpenAPIPath + "-3.0.json", }, func(ctx Context) { @@ -424,7 +437,7 @@ func NewAPI(config Config, a Adapter) API { ctx.BodyWriter().Write(specJSON30) }) var specYAML []byte - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.OpenAPIPath + ".yaml", }, func(ctx Context) { @@ -435,7 +448,7 @@ func NewAPI(config Config, a Adapter) API { ctx.BodyWriter().Write(specYAML) }) var specYAML30 []byte - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.OpenAPIPath + "-3.0.yaml", }, func(ctx Context) { @@ -448,14 +461,10 @@ func NewAPI(config Config, a Adapter) API { } if config.DocsPath != "" { - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.DocsPath, }, func(ctx Context) { - openAPIPath := config.OpenAPIPath - if prefix := getAPIPrefix(newAPI.OpenAPI()); prefix != "" { - openAPIPath = path.Join(prefix, openAPIPath) - } ctx.SetHeader("Content-Type", "text/html") title := "Elements in HTML" if config.Info != nil && config.Info.Title != "" { @@ -476,7 +485,7 @@ func NewAPI(config Config, a Adapter) API { Date: Tue, 25 Mar 2025 10:52:02 +0100 Subject: [PATCH 2/2] feat: actually use prefix parameter in NewSchemaLinkTransformer --- adapters/humachi/humachi_test.go | 3 +++ defaults.go | 7 +++---- transforms.go | 12 +++++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/adapters/humachi/humachi_test.go b/adapters/humachi/humachi_test.go index cc82a531..1fd4341d 100644 --- a/adapters/humachi/humachi_test.go +++ b/adapters/humachi/humachi_test.go @@ -322,6 +322,9 @@ func TestChiRouterPrefix(t *testing.T) { mux.Route("/api", func(r chi.Router) { config := huma.DefaultConfig("My API", "1.0.0") config.Servers = []*huma.Server{{URL: "http://localhost:8888/api"}} + // config.CreateHooks = []func(huma.Config) huma.Config{ + // huma.DefaultSchemaLinkHook("/api"), + // } api = New(r, config) }) diff --git a/defaults.go b/defaults.go index cb094447..99c66509 100644 --- a/defaults.go +++ b/defaults.go @@ -3,7 +3,6 @@ package huma import ( "encoding/json" "io" - "path" ) // DefaultSchemaNamer is the default prefix used to reference schemas in the API spec. @@ -76,7 +75,7 @@ func DefaultConfig(title, version string) Config { Formats: DefaultFormats, DefaultFormat: "application/json", CreateHooks: []func(Config) Config{ - DefaultSchemaLinkHook(DefaultSchemaRefPrefix, ""), + DefaultSchemaLinkHook(""), // assume schemas are on mounted the root }, } } @@ -85,9 +84,9 @@ func DefaultConfig(title, version string) Config { // This adds `Link` headers and puts `$schema` fields in the response body which // point to the JSON Schema that describes the response structure. // This is a create hook so we get the latest schema path setting. -func DefaultSchemaLinkHook(schemaRefPrefix, schemaPathPrefix string) func(c Config) Config { +func DefaultSchemaLinkHook(schemaPathPrefix string) func(c Config) Config { return func(c Config) Config { - linkTransformer := NewSchemaLinkTransformer(schemaRefPrefix, path.Join(schemaPathPrefix, c.SchemasPath)) + linkTransformer := NewSchemaLinkTransformer(schemaPathPrefix, c.SchemasPath) c.OpenAPI.OnAddOperation = append(c.OpenAPI.OnAddOperation, linkTransformer.OnAddOperation) c.Transformers = append(c.Transformers, linkTransformer.Transform) return c diff --git a/transforms.go b/transforms.go index 292cdf27..d092bc28 100644 --- a/transforms.go +++ b/transforms.go @@ -20,7 +20,7 @@ type schemaField struct { // as-you-type validation & completion of HTTP resources in editors like // VSCode. type SchemaLinkTransformer struct { - prefix string + apiPrefix string schemasPath string types map[any]struct { t reflect.Type @@ -36,9 +36,9 @@ type SchemaLinkTransformer struct { // to understand the structure of the response and enables things like // as-you-type validation & completion of HTTP resources in editors like // VSCode. -func NewSchemaLinkTransformer(prefix, schemasPath string) *SchemaLinkTransformer { +func NewSchemaLinkTransformer(apiPrefix, schemasPath string) *SchemaLinkTransformer { return &SchemaLinkTransformer{ - prefix: prefix, + apiPrefix: apiPrefix, schemasPath: schemasPath, types: map[any]struct { t reflect.Type @@ -94,8 +94,10 @@ func (t *SchemaLinkTransformer) OnAddOperation(oapi *OpenAPI, op *Operation) { // Figure out if there should be a base path prefix. This might be set when // using a sub-router / group or if the gateway consumes a part of the path. schemasPath := t.schemasPath - if prefix := getAPIPrefix(oapi); prefix != "" { - schemasPath = path.Join(prefix, schemasPath) + if apiPrefix := getAPIPrefix(oapi); apiPrefix != "" { + schemasPath = path.Join(apiPrefix, schemasPath) + } else if t.apiPrefix != "" { + schemasPath = path.Join(t.apiPrefix, schemasPath) } registry := oapi.Components.Schemas