From 78a1e2c802b6a0d42ea57eb7f790c45e108a0a90 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 21 Mar 2025 23:34:28 +0100 Subject: [PATCH 1/6] 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) ``` --- api.go | 36 ++++++++++++++++++++++++++++-------- defaults.go | 35 ++++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/api.go b/api.go index 2e85b663..a93d821e 100644 --- a/api.go +++ b/api.go @@ -226,6 +226,15 @@ type Config struct { // them here does nothing. DocsRendererConfig any + // 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. @@ -510,7 +519,7 @@ func parseContentType(contentType string) (int, int, error) { // 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) } @@ -556,9 +565,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) { @@ -570,7 +584,7 @@ func NewAPI(config Config, a Adapter) API { }) var specJSON30 []byte - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.OpenAPIPath + "-3.0.json", }, func(ctx Context) { @@ -582,7 +596,7 @@ func NewAPI(config Config, a Adapter) API { }) var specYAML []byte - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.OpenAPIPath + ".yaml", }, func(ctx Context) { @@ -594,7 +608,7 @@ func NewAPI(config Config, a Adapter) API { }) var specYAML30 []byte - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.OpenAPIPath + "-3.0.yaml", }, func(ctx Context) { @@ -611,7 +625,7 @@ func NewAPI(config Config, a Adapter) API { } if config.SchemasPath != "" { - a.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.SchemasPath + "/{schema}", }, func(ctx Context) { @@ -628,8 +642,14 @@ func NewAPI(config Config, a Adapter) API { } func (a *api) registerDocsRoute() { + docsAdapter := a.adapter openAPIPath := a.config.OpenAPIPath - if prefix := getAPIPrefix(a.OpenAPI()); prefix != "" { + if a.config.DocsAdapter != nil { + // The API prefix describes the main adapter, not the docs adapter, so + // link to the spec relative to the docs page instead. + docsAdapter = a.config.DocsAdapter + openAPIPath = strings.TrimPrefix(openAPIPath, "/") + } else if prefix := getAPIPrefix(a.OpenAPI()); prefix != "" { openAPIPath = path.Join(prefix, openAPIPath) } @@ -780,7 +800,7 @@ func (a *api) registerDocsRoute() { panic("missing CSP for docs renderer: " + a.config.DocsRenderer) } - a.adapter.Handle(&Operation{ + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: a.config.DocsPath, }, func(ctx Context) { diff --git a/defaults.go b/defaults.go index 79a01db8..d318890f 100644 --- a/defaults.go +++ b/defaults.go @@ -3,8 +3,12 @@ package huma import ( "encoding/json" "io" + "path" ) +// DefaultSchemaNamer is the default prefix used to reference schemas in the API spec. +const DefaultSchemaRefPrefix = "#/components/schemas/" + // DefaultJSONFormat is the default JSON formatter that can be set in the API's // `Config.Formats` map. This is used by the `DefaultConfig` function. // @@ -53,10 +57,7 @@ var DefaultFormats = map[string]Format{ // // import _ "github.com/danielgtaylor/huma/v2/formats/cbor" func DefaultConfig(title string, version string) Config { - schemaPrefix := "#/components/schemas/" - schemasPath := "/schemas" - - registry := NewMapRegistry(schemaPrefix, DefaultSchemaNamer) + registry := NewMapRegistry(DefaultSchemaRefPrefix, DefaultSchemaNamer) return Config{ OpenAPI: &OpenAPI{ @@ -73,23 +74,27 @@ func DefaultConfig(title string, version string) Config { registryConfig: getConfig[registryConfig](registry), DocsPath: "/docs", DocsRenderer: DocsRendererStoplightElements, - SchemasPath: schemasPath, + SchemasPath: "/schemas", Formats: DefaultFormats, DefaultFormat: "application/json", NoFormatFallback: false, RejectUnknownQueryParameters: false, Transformers: nil, CreateHooks: []func(Config) Config{ - func(c Config) Config { - // Add a link transformer to the API. 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. - linkTransformer := NewSchemaLinkTransformer(schemaPrefix, c.SchemasPath) - c.OnAddOperation = append(c.OnAddOperation, linkTransformer.OnAddOperation) - c.Transformers = append(c.Transformers, linkTransformer.Transform) - return c - }, + DefaultSchemaLinkHook(DefaultSchemaRefPrefix, ""), }, } } + +// DefaultSchemaLinkHook adds a link transformer to the API. +// 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 { + return func(c Config) Config { + linkTransformer := NewSchemaLinkTransformer(schemaRefPrefix, path.Join(schemaPathPrefix, c.SchemasPath)) + c.OnAddOperation = append(c.OnAddOperation, linkTransformer.OnAddOperation) + c.Transformers = append(c.Transformers, linkTransformer.Transform) + return c + } +} From 9aa642d125e86df713f58fe4fade655896adff43 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Tue, 25 Mar 2025 10:52:02 +0100 Subject: [PATCH 2/6] 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 08deee89..0b521bff 100644 --- a/adapters/humachi/humachi_test.go +++ b/adapters/humachi/humachi_test.go @@ -323,6 +323,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 d318890f..43fa792b 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. @@ -81,7 +80,7 @@ func DefaultConfig(title string, version string) Config { RejectUnknownQueryParameters: false, Transformers: nil, CreateHooks: []func(Config) Config{ - DefaultSchemaLinkHook(DefaultSchemaRefPrefix, ""), + DefaultSchemaLinkHook(""), // assume schemas are on mounted the root }, } } @@ -90,9 +89,9 @@ func DefaultConfig(title string, 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.OnAddOperation = append(c.OnAddOperation, linkTransformer.OnAddOperation) c.Transformers = append(c.Transformers, linkTransformer.Transform) return c diff --git a/transforms.go b/transforms.go index 10d3d6f0..3e3e2f2d 100644 --- a/transforms.go +++ b/transforms.go @@ -21,7 +21,7 @@ type schemaField struct { // VSCode. type SchemaLinkTransformer struct { oapi *OpenAPI - prefix string + apiPrefix string schemasPath string types map[any][]schemaLinkType } @@ -40,9 +40,9 @@ type schemaLinkType 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][]schemaLinkType{}, } @@ -96,8 +96,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 From d89c772bd05aea1a4536883f9c3ed218c5ef6706 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Thu, 29 Oct 2026 09:16:49 +0100 Subject: [PATCH 3/6] test: cover docs on a separate adapter Make sure that the main adapter does not serve the docs and the spec when DocsAdapter is set. Also make sure that the docs link to the spec with a relative URL, because the server prefix applies only to the main adapter. Co-Authored-By: Claude Opus 5.5 --- api_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/api_test.go b/api_test.go index 85b3964e..f9a8736a 100644 --- a/api_test.go +++ b/api_test.go @@ -3,6 +3,7 @@ package huma_test import ( "context" "net/http" + "net/http/httptest" "testing" "github.com/danielgtaylor/huma/v2" @@ -332,6 +333,37 @@ func TestDocsRenderers(t *testing.T) { assert.Contains(t, resp.Body.String(), `apiDescriptionUrl="/api/v1/openapi.yaml"`) }) + t.Run("DocsAdapter", func(t *testing.T) { + docsAdapter := humatest.NewAdapter() + _, api := humatest.New(t, huma.Config{ + OpenAPI: &huma.OpenAPI{ + Info: &huma.Info{Title: "Test API", Version: "1.0.0"}, + Servers: []*huma.Server{ + {URL: "https://example.com/api/v1"}, + }, + }, + DocsPath: "/docs", + DocsAdapter: docsAdapter, + OpenAPIPath: "/openapi", + Formats: huma.DefaultFormats, + }) + + // The main adapter does not serve the docs or the spec. + assert.Equal(t, http.StatusNotFound, api.Get("/docs").Code) + assert.Equal(t, http.StatusNotFound, api.Get("/openapi.yaml").Code) + + w := httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil)) + assert.Equal(t, http.StatusOK, w.Code) + // The server prefix applies to the main adapter only, so the spec URL is + // relative to the docs page. + assert.Contains(t, w.Body.String(), `apiDescriptionUrl="openapi.yaml"`) + + w = httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil)) + assert.Equal(t, http.StatusOK, w.Code) + }) + t.Run("APIPrefixWithServerVars", func(t *testing.T) { _, api := humatest.New(t, huma.Config{ OpenAPI: &huma.OpenAPI{ From 6c68797af3456e608c9562966d2e7088302add70 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 30 Oct 2026 15:05:00 +0100 Subject: [PATCH 4/6] fix: let the schema link hook prefix win over the server URL When DocsAdapter serves the schemas, the server URL describes the main API only. The hook prefix must replace the server prefix, also in the $schema example and in the host fallback. Otherwise the links point to a path that does not serve the schemas. Keep the meaning of the NewSchemaLinkTransformer arguments as on main, so current callers do not get broken links. Do not export a new constant for the schema ref prefix. Panic at startup when DocsAdapter serves the schemas but the hook has no prefix, or when the prefix is not a path. Huma cannot see where an adapter is mounted, so this makes the mount path explicit. Co-Authored-By: Claude Opus 5.5 --- adapters/humachi/humachi_test.go | 3 - api.go | 17 ++-- api_test.go | 131 +++++++++++++++++++++++-------- defaults.go | 46 ++++++++--- transforms.go | 46 +++++++++-- 5 files changed, 182 insertions(+), 61 deletions(-) diff --git a/adapters/humachi/humachi_test.go b/adapters/humachi/humachi_test.go index 0b521bff..08deee89 100644 --- a/adapters/humachi/humachi_test.go +++ b/adapters/humachi/humachi_test.go @@ -323,9 +323,6 @@ 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/api.go b/api.go index a93d821e..ebb8724d 100644 --- a/api.go +++ b/api.go @@ -226,13 +226,16 @@ type Config struct { // them here does nothing. DocsRendererConfig any - // 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 is an optional [Adapter] that serves the OpenAPI spec, the + // docs and the schemas. If unset, the main adapter serves them. Use it to + // put them on a different router or middleware stack, e.g. to hide them + // from the public API. + // + // The responses of the main API link to the schemas. If this adapter serves + // the schemas, replace the default create hook with [DefaultSchemaLinkHook] + // and give it the path where this adapter is mounted. Huma cannot detect + // this path, and the links work only if this adapter is on the same host + // as the main API. DocsAdapter Adapter // SchemasPath is the path to the API schemas. If set to `/schemas` it will diff --git a/api_test.go b/api_test.go index f9a8736a..e96dd1ea 100644 --- a/api_test.go +++ b/api_test.go @@ -333,37 +333,6 @@ func TestDocsRenderers(t *testing.T) { assert.Contains(t, resp.Body.String(), `apiDescriptionUrl="/api/v1/openapi.yaml"`) }) - t.Run("DocsAdapter", func(t *testing.T) { - docsAdapter := humatest.NewAdapter() - _, api := humatest.New(t, huma.Config{ - OpenAPI: &huma.OpenAPI{ - Info: &huma.Info{Title: "Test API", Version: "1.0.0"}, - Servers: []*huma.Server{ - {URL: "https://example.com/api/v1"}, - }, - }, - DocsPath: "/docs", - DocsAdapter: docsAdapter, - OpenAPIPath: "/openapi", - Formats: huma.DefaultFormats, - }) - - // The main adapter does not serve the docs or the spec. - assert.Equal(t, http.StatusNotFound, api.Get("/docs").Code) - assert.Equal(t, http.StatusNotFound, api.Get("/openapi.yaml").Code) - - w := httptest.NewRecorder() - docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil)) - assert.Equal(t, http.StatusOK, w.Code) - // The server prefix applies to the main adapter only, so the spec URL is - // relative to the docs page. - assert.Contains(t, w.Body.String(), `apiDescriptionUrl="openapi.yaml"`) - - w = httptest.NewRecorder() - docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil)) - assert.Equal(t, http.StatusOK, w.Code) - }) - t.Run("APIPrefixWithServerVars", func(t *testing.T) { _, api := humatest.New(t, huma.Config{ OpenAPI: &huma.OpenAPI{ @@ -498,3 +467,103 @@ func TestDocsRenderers(t *testing.T) { }) }) } + +func TestDocsAdapter(t *testing.T) { + t.Run("Docs", func(t *testing.T) { + docsAdapter := humatest.NewAdapter() + _, api := humatest.New(t, huma.Config{ + OpenAPI: &huma.OpenAPI{ + Info: &huma.Info{Title: "Test API", Version: "1.0.0"}, + Servers: []*huma.Server{ + {URL: "https://example.com/api/v1"}, + }, + }, + DocsPath: "/docs", + DocsAdapter: docsAdapter, + OpenAPIPath: "/openapi", + Formats: huma.DefaultFormats, + }) + + // The main adapter does not serve the docs or the spec. + assert.Equal(t, http.StatusNotFound, api.Get("/docs").Code) + assert.Equal(t, http.StatusNotFound, api.Get("/openapi.yaml").Code) + + w := httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil)) + assert.Equal(t, http.StatusOK, w.Code) + // The server prefix applies to the main adapter only, so the spec URL is + // relative to the docs page. + assert.Contains(t, w.Body.String(), `apiDescriptionUrl="openapi.yaml"`) + + w = httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil)) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("Schemas", func(t *testing.T) { + type Greeting struct { + Message string `json:"message"` + } + + docsAdapter := humatest.NewAdapter() + config := huma.DefaultConfig("Test API", "1.0.0") + config.Servers = []*huma.Server{{URL: "https://api.example.com/api/v1"}} + config.DocsAdapter = docsAdapter + config.CreateHooks = []func(huma.Config) huma.Config{ + huma.DefaultSchemaLinkHook("/internal"), + } + _, api := humatest.New(t, config) + + huma.Get(api, "/greeting", func(ctx context.Context, input *struct{}) (*struct{ Body Greeting }, error) { + return &struct{ Body Greeting }{Body: Greeting{Message: "hello"}}, nil + }) + + // The hook prefix replaces the server prefix, because the docs adapter + // serves the schemas. + resp := api.Get("/greeting") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `; rel="describedBy"`, resp.Header().Get("Link")) + assert.Contains(t, resp.Body.String(), `"$schema":"https://api.example.com/internal/schemas/Greeting.json"`) + + schema := api.OpenAPI().Components.Schemas.Map()["Greeting"] + assert.Equal(t, []any{"https://api.example.com/internal/schemas/Greeting.json"}, schema.Properties["$schema"].Examples) + + // The docs adapter serves the schemas, the main adapter does not. + assert.Equal(t, http.StatusNotFound, api.Get("/schemas/Greeting.json").Code) + w := httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/schemas/Greeting.json", nil)) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("SchemaLinkPrefixRoot", func(t *testing.T) { + config := huma.DefaultConfig("Test API", "1.0.0") + config.Servers = []*huma.Server{{URL: "https://api.example.com/api/v1"}} + config.DocsAdapter = humatest.NewAdapter() + config.CreateHooks = []func(huma.Config) huma.Config{ + huma.DefaultSchemaLinkHook("/"), + } + _, api := humatest.New(t, config) + + huma.Get(api, "/greeting", func(ctx context.Context, input *struct{}) (*struct{ Body struct{} }, error) { + return &struct{ Body struct{} }{}, nil + }) + + resp := api.Get("/greeting") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `; rel="describedBy"`, resp.Header().Get("Link")) + }) + + t.Run("SchemaLinkPrefixMissing", func(t *testing.T) { + config := huma.DefaultConfig("Test API", "1.0.0") + config.DocsAdapter = humatest.NewAdapter() + assert.PanicsWithValue(t, `DocsAdapter serves the schemas, so DefaultSchemaLinkHook needs the path where DocsAdapter is mounted (use "/" for the root)`, func() { + humatest.New(t, config) + }) + }) + + t.Run("SchemaLinkPrefixInvalid", func(t *testing.T) { + assert.PanicsWithValue(t, "schema link path prefix must start with a slash: internal", func() { + huma.DefaultSchemaLinkHook("internal") + }) + }) +} diff --git a/defaults.go b/defaults.go index 43fa792b..9c6aa5f4 100644 --- a/defaults.go +++ b/defaults.go @@ -3,11 +3,9 @@ package huma import ( "encoding/json" "io" + "strings" ) -// DefaultSchemaNamer is the default prefix used to reference schemas in the API spec. -const DefaultSchemaRefPrefix = "#/components/schemas/" - // DefaultJSONFormat is the default JSON formatter that can be set in the API's // `Config.Formats` map. This is used by the `DefaultConfig` function. // @@ -56,7 +54,10 @@ var DefaultFormats = map[string]Format{ // // import _ "github.com/danielgtaylor/huma/v2/formats/cbor" func DefaultConfig(title string, version string) Config { - registry := NewMapRegistry(DefaultSchemaRefPrefix, DefaultSchemaNamer) + schemaPrefix := "#/components/schemas/" + schemasPath := "/schemas" + + registry := NewMapRegistry(schemaPrefix, DefaultSchemaNamer) return Config{ OpenAPI: &OpenAPI{ @@ -73,25 +74,46 @@ func DefaultConfig(title string, version string) Config { registryConfig: getConfig[registryConfig](registry), DocsPath: "/docs", DocsRenderer: DocsRendererStoplightElements, - SchemasPath: "/schemas", + SchemasPath: schemasPath, Formats: DefaultFormats, DefaultFormat: "application/json", NoFormatFallback: false, RejectUnknownQueryParameters: false, Transformers: nil, CreateHooks: []func(Config) Config{ - DefaultSchemaLinkHook(""), // assume schemas are on mounted the root + DefaultSchemaLinkHook(""), }, } } -// DefaultSchemaLinkHook adds a link transformer to the API. -// 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(schemaPathPrefix string) func(c Config) Config { +// DefaultSchemaLinkHook returns a create hook that adds a link transformer to +// the API. The transformer adds `Link` headers and `$schema` fields to response +// bodies. These point to the JSON Schema that describes the response +// structure. It is a create hook so it gets the latest schema path setting. +// +// The pathPrefix is the path where the adapter that serves the schemas is +// mounted, from the root of the host, e.g. `/internal`. Use `/` for the root. +// If it is empty, the path of the first server URL is used. You must set it +// when [Config.DocsAdapter] serves the schemas, because the server URLs +// describe the main API only. +// +// config := huma.DefaultConfig("My API", "1.0.0") +// config.DocsAdapter = humachi.NewAdapter(internalRouter) +// config.CreateHooks = []func(huma.Config) huma.Config{ +// huma.DefaultSchemaLinkHook("/internal"), +// } +func DefaultSchemaLinkHook(pathPrefix string) func(Config) Config { + if pathPrefix != "" && !strings.HasPrefix(pathPrefix, "/") { + panic("schema link path prefix must start with a slash: " + pathPrefix) + } + return func(c Config) Config { - linkTransformer := NewSchemaLinkTransformer(schemaPathPrefix, c.SchemasPath) + if c.DocsAdapter != nil && c.SchemasPath != "" && pathPrefix == "" { + panic(`DocsAdapter serves the schemas, so DefaultSchemaLinkHook needs the path where DocsAdapter is mounted (use "/" for the root)`) + } + + linkTransformer := NewSchemaLinkTransformer(schemaRefPrefix, c.SchemasPath) + linkTransformer.pathPrefix = pathPrefix c.OnAddOperation = append(c.OnAddOperation, linkTransformer.OnAddOperation) c.Transformers = append(c.Transformers, linkTransformer.Transform) return c diff --git a/transforms.go b/transforms.go index 3e3e2f2d..efa92983 100644 --- a/transforms.go +++ b/transforms.go @@ -21,9 +21,13 @@ type schemaField struct { // VSCode. type SchemaLinkTransformer struct { oapi *OpenAPI - apiPrefix string + prefix string schemasPath string types map[any][]schemaLinkType + + // pathPrefix replaces the API prefix from the server URL when it is set. + // See DefaultSchemaLinkHook. + pathPrefix string } type schemaLinkType struct { @@ -40,9 +44,9 @@ type schemaLinkType 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(apiPrefix, schemasPath string) *SchemaLinkTransformer { +func NewSchemaLinkTransformer(prefix, schemasPath string) *SchemaLinkTransformer { return &SchemaLinkTransformer{ - apiPrefix: apiPrefix, + prefix: prefix, schemasPath: schemasPath, types: map[any][]schemaLinkType{}, } @@ -68,12 +72,19 @@ func (t *SchemaLinkTransformer) addSchemaField(oapi *OpenAPI, content *MediaType } } + schemasPath := t.schemasPath + if t.pathPrefix != "" { + // The path prefix replaces the path of the server URL. + server = serverOrigin(server) + schemasPath = path.Join(t.pathPrefix, schemasPath) + } + schema.Properties["$schema"] = &Schema{ Type: TypeString, Format: "uri", Description: "A URL to the JSON Schema for this object.", ReadOnly: true, - Examples: []any{server + t.schemasPath + "/" + path.Base(content.Schema.Ref) + ".json"}, + Examples: []any{server + schemasPath + "/" + path.Base(content.Schema.Ref) + ".json"}, } return false @@ -96,10 +107,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 apiPrefix := getAPIPrefix(oapi); apiPrefix != "" { - schemasPath = path.Join(apiPrefix, schemasPath) - } else if t.apiPrefix != "" { - schemasPath = path.Join(t.apiPrefix, schemasPath) + if t.pathPrefix != "" { + schemasPath = path.Join(t.pathPrefix, schemasPath) + } else if prefix := getAPIPrefix(oapi); prefix != "" { + schemasPath = path.Join(prefix, schemasPath) } registry := oapi.Components.Schemas @@ -202,6 +213,10 @@ func (t *SchemaLinkTransformer) Transform(ctx Context, _ string, v any) (any, er if schemaURL == "" || strings.HasPrefix(schemaURL, "localhost") { if t.oapi != nil && len(t.oapi.Servers) > 0 && t.oapi.Servers[0].URL != "" { serverURL := t.oapi.Servers[0].URL + if t.pathPrefix != "" { + // The ref already starts with the path prefix. + serverURL = serverOrigin(serverURL) + } if !strings.HasPrefix(serverURL, "/") { schemaURL = serverURL } @@ -259,3 +274,18 @@ func getSchemaHost(ctx Context) string { return ctx.Host() } + +// serverOrigin returns the server URL without its path, e.g. +// `https://example.com` for `https://example.com/api/v1`. It works on the raw +// URL, so server variables like `{port}` are kept as they are. +func serverOrigin(server string) string { + if strings.HasPrefix(server, "/") { + return "" + } + if i := strings.Index(server, "://"); i >= 0 { + if j := strings.IndexByte(server[i+3:], '/'); j >= 0 { + return server[:i+3+j] + } + } + return server +} From 8ae0a9774692a5ef54d29a4621eeb7d5f3ab04f3 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 30 Oct 2026 15:05:14 +0100 Subject: [PATCH 5/6] fix: restore the CreateHooks loop A range loop reads the length of the hooks only once. Hooks that add other hooks then do not run them, and hooks that remove hooks cause a panic. Co-Authored-By: Claude Opus 5.5 --- api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.go b/api.go index ebb8724d..8ba59043 100644 --- a/api.go +++ b/api.go @@ -522,7 +522,7 @@ func parseContentType(contentType string) (int, int, error) { // config := huma.DefaultConfig("Example API", "1.0.0") // api := huma.NewAPI(config, adapter) func NewAPI(config Config, a Adapter) API { - for i := range config.CreateHooks { + for i := 0; i < len(config.CreateHooks); i++ { config = config.CreateHooks[i](config) } From bb2ec388d72e2cce23350f39e904126bd98512cd Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 30 Oct 2026 15:05:54 +0100 Subject: [PATCH 6/6] fix: resolve docs and schema URLs under nested paths With DocsAdapter, the spec URL in the docs page is relative. It resolved to the wrong path when DocsPath has more than one segment, e.g. /reference/docs. Nested refs in the served schemas used SchemasPath without the mount path of the docs adapter, so they did not resolve either. Both are now relative to the page or schema that contains them. Co-Authored-By: Claude Opus 5.5 --- api.go | 14 +++++++++++--- api_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/api.go b/api.go index 8ba59043..1e0c9f82 100644 --- a/api.go +++ b/api.go @@ -628,6 +628,12 @@ func NewAPI(config Config, a Adapter) API { } if config.SchemasPath != "" { + schemaRef := []byte(config.SchemasPath + `/$1.json`) + if config.DocsAdapter != nil { + // The docs adapter can be mounted under any path, so nested refs are + // relative to the schema that contains them. + schemaRef = []byte(`$1.json`) + } docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.SchemasPath + "/{schema}", @@ -636,7 +642,7 @@ func NewAPI(config Config, a Adapter) API { schema := strings.TrimSuffix(ctx.Param("schema"), ".json") ctx.SetHeader("Content-Type", "application/json") b, _ := json.Marshal(config.Components.Schemas.Map()[schema]) - b = rxSchema.ReplaceAll(b, []byte(config.SchemasPath+`/$1.json`)) + b = rxSchema.ReplaceAll(b, schemaRef) ctx.BodyWriter().Write(b) }) } @@ -649,9 +655,11 @@ func (a *api) registerDocsRoute() { openAPIPath := a.config.OpenAPIPath if a.config.DocsAdapter != nil { // The API prefix describes the main adapter, not the docs adapter, so - // link to the spec relative to the docs page instead. + // link to the spec relative to the docs page instead. Each segment of + // the docs path after the first one needs a step up. docsAdapter = a.config.DocsAdapter - openAPIPath = strings.TrimPrefix(openAPIPath, "/") + up := strings.Repeat("../", max(strings.Count(a.config.DocsPath, "/")-1, 0)) + openAPIPath = up + strings.TrimPrefix(openAPIPath, "/") } else if prefix := getAPIPrefix(a.OpenAPI()); prefix != "" { openAPIPath = path.Join(prefix, openAPIPath) } diff --git a/api_test.go b/api_test.go index e96dd1ea..61cb68be 100644 --- a/api_test.go +++ b/api_test.go @@ -500,6 +500,55 @@ func TestDocsAdapter(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) }) + t.Run("NestedDocsPath", func(t *testing.T) { + for docsPath, specURL := range map[string]string{ + "/reference/docs": "../openapi.yaml", + "/docs/": "../openapi.yaml", + "/a/b/docs": "../../openapi.yaml", + } { + docsAdapter := humatest.NewAdapter() + humatest.New(t, huma.Config{ + OpenAPI: &huma.OpenAPI{Info: &huma.Info{Title: "Test API", Version: "1.0.0"}}, + DocsPath: docsPath, + DocsAdapter: docsAdapter, + OpenAPIPath: "/openapi", + Formats: huma.DefaultFormats, + }) + + w := httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, docsPath, nil)) + assert.Equal(t, http.StatusOK, w.Code, docsPath) + assert.Contains(t, w.Body.String(), `apiDescriptionUrl="`+specURL+`"`, docsPath) + } + }) + + t.Run("NestedSchemaRef", func(t *testing.T) { + type Inner struct { + Value string `json:"value"` + } + type Outer struct { + Inner Inner `json:"inner"` + } + + docsAdapter := humatest.NewAdapter() + config := huma.DefaultConfig("Test API", "1.0.0") + config.DocsAdapter = docsAdapter + config.CreateHooks = []func(huma.Config) huma.Config{ + huma.DefaultSchemaLinkHook("/internal"), + } + _, api := humatest.New(t, config) + + huma.Get(api, "/outer", func(ctx context.Context, input *struct{}) (*struct{ Body Outer }, error) { + return &struct{ Body Outer }{}, nil + }) + + // The nested ref is relative, so it works under any mount path. + w := httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/schemas/Outer.json", nil)) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"$ref":"Inner.json"`) + }) + t.Run("Schemas", func(t *testing.T) { type Greeting struct { Message string `json:"message"`