From 939cb40a736fa33e90f7479c390e9e7adfb07b49 Mon Sep 17 00:00:00 2001 From: "Mr. Khachaturov" <105451445+mrkhachaturov@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:25:46 +0300 Subject: [PATCH] fix: include Pydantic v2 fields in ValidationError OpenAPI schema (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI generates the ValidationError schema with only loc, msg, and type, but Pydantic v2 actually returns input, ctx, and url as well. Generated clients with strict JSON decoding (Go's DisallowUnknownFields) cannot parse real 422 responses — the actual validation message gets replaced by a confusing JSON decoding error. - Patch the OpenAPI schema in create_app() to add input, ctx, url - Regenerate spec and Go client --- hindsight-api-slim/hindsight_api/api/http.py | 20 ++++ hindsight-clients/go/api/openapi.yaml | 16 +++ .../go/model_validation_error.go | 109 ++++++++++++++++++ hindsight-docs/static/openapi.json | 11 ++ 4 files changed, 156 insertions(+) diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 799cfee7..cda32987 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -2064,6 +2064,26 @@ def create_app( # This is required for mounted sub-applications where lifespan may not fire app.state.memory = memory + # --------------------------------------------------------------------------- + # Patch OpenAPI schema: align ValidationError with Pydantic v2 error format + # --------------------------------------------------------------------------- + # FastAPI auto-generates ValidationError with only loc/msg/type, but Pydantic + # v2 actually returns additional fields: input (the rejected value), ctx (extra + # context dict), and url (link to error docs). Without these in the spec, + # generated clients using strict JSON decoding break on real 422 responses. + _original_openapi = app.openapi + + def _patched_openapi() -> dict[str, Any]: + schema = _original_openapi() + ve = schema.get("components", {}).get("schemas", {}).get("ValidationError") + if ve and "input" not in ve.get("properties", {}): + ve["properties"]["input"] = {"title": "Input"} + ve["properties"]["ctx"] = {"title": "Context", "type": "object"} + ve["properties"]["url"] = {"title": "URL", "type": "string"} + return schema + + app.openapi = _patched_openapi # type: ignore[assignment] + # Add HTTP metrics middleware @app.middleware("http") async def http_metrics_middleware(request, call_next): diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 3007f8bd..de015606 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -3884,12 +3884,18 @@ components: loc: - ValidationError_loc_inner - ValidationError_loc_inner + input: "" + ctx: "{}" type: type + url: url - msg: msg loc: - ValidationError_loc_inner - ValidationError_loc_inner + input: "" + ctx: "{}" type: type + url: url properties: detail: items: @@ -5202,7 +5208,10 @@ components: loc: - ValidationError_loc_inner - ValidationError_loc_inner + input: "" + ctx: "{}" type: type + url: url properties: loc: items: @@ -5214,6 +5223,13 @@ components: type: title: Error Type type: string + input: {} + ctx: + title: Context + type: object + url: + title: URL + type: string required: - loc - msg diff --git a/hindsight-clients/go/model_validation_error.go b/hindsight-clients/go/model_validation_error.go index 50255c64..576f07f3 100644 --- a/hindsight-clients/go/model_validation_error.go +++ b/hindsight-clients/go/model_validation_error.go @@ -24,6 +24,9 @@ type ValidationError struct { Loc []ValidationErrorLocInner `json:"loc"` Msg string `json:"msg"` Type string `json:"type"` + Input interface{} `json:"input,omitempty"` + Ctx map[string]interface{} `json:"ctx,omitempty"` + Url *string `json:"url,omitempty"` } type _ValidationError ValidationError @@ -120,6 +123,103 @@ func (o *ValidationError) SetType(v string) { o.Type = v } +// GetInput returns the Input field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *ValidationError) GetInput() interface{} { + if o == nil { + var ret interface{} + return ret + } + return o.Input +} + +// GetInputOk returns a tuple with the Input field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *ValidationError) GetInputOk() (*interface{}, bool) { + if o == nil || IsNil(o.Input) { + return nil, false + } + return &o.Input, true +} + +// HasInput returns a boolean if a field has been set. +func (o *ValidationError) HasInput() bool { + if o != nil && !IsNil(o.Input) { + return true + } + + return false +} + +// SetInput gets a reference to the given interface{} and assigns it to the Input field. +func (o *ValidationError) SetInput(v interface{}) { + o.Input = v +} + +// GetCtx returns the Ctx field value if set, zero value otherwise. +func (o *ValidationError) GetCtx() map[string]interface{} { + if o == nil || IsNil(o.Ctx) { + var ret map[string]interface{} + return ret + } + return o.Ctx +} + +// GetCtxOk returns a tuple with the Ctx field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ValidationError) GetCtxOk() (map[string]interface{}, bool) { + if o == nil || IsNil(o.Ctx) { + return map[string]interface{}{}, false + } + return o.Ctx, true +} + +// HasCtx returns a boolean if a field has been set. +func (o *ValidationError) HasCtx() bool { + if o != nil && !IsNil(o.Ctx) { + return true + } + + return false +} + +// SetCtx gets a reference to the given map[string]interface{} and assigns it to the Ctx field. +func (o *ValidationError) SetCtx(v map[string]interface{}) { + o.Ctx = v +} + +// GetUrl returns the Url field value if set, zero value otherwise. +func (o *ValidationError) GetUrl() string { + if o == nil || IsNil(o.Url) { + var ret string + return ret + } + return *o.Url +} + +// GetUrlOk returns a tuple with the Url field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ValidationError) GetUrlOk() (*string, bool) { + if o == nil || IsNil(o.Url) { + return nil, false + } + return o.Url, true +} + +// HasUrl returns a boolean if a field has been set. +func (o *ValidationError) HasUrl() bool { + if o != nil && !IsNil(o.Url) { + return true + } + + return false +} + +// SetUrl gets a reference to the given string and assigns it to the Url field. +func (o *ValidationError) SetUrl(v string) { + o.Url = &v +} + func (o ValidationError) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -133,6 +233,15 @@ func (o ValidationError) ToMap() (map[string]interface{}, error) { toSerialize["loc"] = o.Loc toSerialize["msg"] = o.Msg toSerialize["type"] = o.Type + if o.Input != nil { + toSerialize["input"] = o.Input + } + if !IsNil(o.Ctx) { + toSerialize["ctx"] = o.Ctx + } + if !IsNil(o.Url) { + toSerialize["url"] = o.Url + } return toSerialize, nil } diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index 866e5a7d..d5116425 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -8265,6 +8265,17 @@ "type": { "type": "string", "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "title": "Context", + "type": "object" + }, + "url": { + "title": "URL", + "type": "string" } }, "type": "object",