danielgtaylor / huma

Huma REST/HTTP API Framework for Golang with OpenAPI 3.1
https://huma.rocks/
MIT License
2.07k stars 148 forks source link

Validation with `discriminator.mapping` #533

Closed superstas closed 2 months ago

superstas commented 3 months ago

Hi there,

I have a question about validating a request by a spec with discriminator and mapping.

In this example I follow to Mapping Type Names from here: https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/

This is my code:

package main

import (
    "context"
    "fmt"
    "net/http"
    "reflect"

    "github.com/danielgtaylor/huma/v2"
    "github.com/danielgtaylor/huma/v2/adapters/humachi"
    "github.com/go-chi/chi/v5"
)

var (
    _ huma.SchemaTransformer = &Equal[string]{}
    _ huma.SchemaTransformer = &In[string]{}
)

type Name string

func (n *Name) TransformSchema(r huma.Registry, s *huma.Schema) *huma.Schema {
    s.Description = fmt.Sprintf("A name with value %s.", *n)
    min, max := 1, 100
    s.MinLength = &min
    s.MaxLength = &max
    return s
}

type Equal[T ~string] struct {
    Operator string `json:"operator" enum:"EQUAL" doc:"The operator to use for the comparison."`
    Value    T      `json:"value" doc:"The value to compare against."`
}

func (e *Equal[T]) TransformSchema(r huma.Registry, s *huma.Schema) *huma.Schema {
    s.Description = fmt.Sprintf("An EQUAL comparison with %T.", e.Value)
    return s
}

type In[T ~string] struct {
    Operator string `json:"operator" enum:"IN" doc:"The operator to use for the comparison."`
    Values   []T    `json:"values" minItems:"1" doc:"The values to compare against."`
}

func (i *In[T]) TransformSchema(r huma.Registry, s *huma.Schema) *huma.Schema {
    s.Description = fmt.Sprintf("An IN comparison with %T.", i.Values)
    return s
}

type Predicate[T ~string] struct {
    *Equal[T]
    *In[T]
}

func (p Predicate[T]) Schema(r huma.Registry) *huma.Schema {
    equalSchema := r.Schema(reflect.TypeOf(p.Equal), true, "")
    inSchema := r.Schema(reflect.TypeOf(p.In), true, "")

    schema := huma.Schema{
        Type:        "object",
        Description: "A predicate for comparing values.",
        OneOf: []*huma.Schema{
            {Ref: equalSchema.Ref},
            {Ref: inSchema.Ref},
        },
        Discriminator: &huma.Discriminator{
            PropertyName: "operator",
            Mapping: map[string]string{
                "EQUAL": equalSchema.Ref,
                "IN":    inSchema.Ref,
            },
        },
    }

    return &schema
}

type Input struct {
    Body struct {
        Name Predicate[Name] `json:"name" doc:"The name to compare."`
    }
}

type Output struct {
    Body struct {
        Message string `json:"message"`
    }
}

func main() {
    router := chi.NewMux()
    api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))

    huma.Post(api, "/input", func(ctx context.Context, input *Input) (*Output, error) {
        resp := &Output{}
        resp.Body.Message = "It works!"
        return resp, nil
    })

    http.ListenAndServe("127.0.0.1:8888", router)
}

This is the spec I get:

spec ```yaml components: schemas: EqualName: additionalProperties: false description: An EQUAL comparison with main.Name. properties: operator: description: The operator to use for the comparison. enum: - EQUAL type: string value: description: The value to compare against. maxLength: 100 minLength: 1 type: string required: - operator - value type: object ErrorDetail: additionalProperties: false properties: location: description: Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' type: string message: description: Error message text type: string value: description: The value at the given location type: object ErrorModel: additionalProperties: false properties: $schema: description: A URL to the JSON Schema for this object. examples: - https://example.com/schemas/ErrorModel.json format: uri readOnly: true type: string detail: description: A human-readable explanation specific to this occurrence of the problem. examples: - Property foo is required but is missing. type: string errors: description: Optional list of individual error details items: $ref: "#/components/schemas/ErrorDetail" type: - array - "null" instance: description: A URI reference that identifies the specific occurrence of the problem. examples: - https://example.com/error-log/abc123 format: uri type: string status: description: HTTP status code examples: - 400 format: int64 type: integer title: description: A short, human-readable summary of the problem type. This value should not change between occurrences of the error. examples: - Bad Request type: string type: default: about:blank description: A URI reference to human-readable documentation for the error. examples: - https://example.com/errors/example format: uri type: string type: object InName: additionalProperties: false description: An IN comparison with []main.Name. properties: operator: description: The operator to use for the comparison. enum: - IN type: string values: description: The values to compare against. items: description: A name with value . maxLength: 100 minLength: 1 type: string minItems: 1 type: - array - "null" required: - operator - values type: object InputBody: additionalProperties: false properties: $schema: description: A URL to the JSON Schema for this object. examples: - https://example.com/schemas/InputBody.json format: uri readOnly: true type: string name: description: The name to compare. discriminator: mapping: EQUAL: "#/components/schemas/EqualName" IN: "#/components/schemas/InName" propertyName: operator oneOf: - $ref: "#/components/schemas/EqualName" - $ref: "#/components/schemas/InName" type: object required: - name type: object OutputBody: additionalProperties: false properties: $schema: description: A URL to the JSON Schema for this object. examples: - https://example.com/schemas/OutputBody.json format: uri readOnly: true type: string message: type: string required: - message type: object info: title: My API version: 1.0.0 openapi: 3.1.0 paths: /input: post: operationId: post-input requestBody: content: application/json: schema: $ref: "#/components/schemas/InputBody" required: true responses: "200": content: application/json: schema: $ref: "#/components/schemas/OutputBody" description: OK default: content: application/problem+json: schema: $ref: "#/components/schemas/ErrorModel" description: Error summary: Post input ```

All looks good.

The problem

When I send an invalid request, for example

>$ curl --request POST --url http://127.0.0.1:8888/input --header 'Accept: application/json, application/problem+json' --header 'Content-Type: application/json' --data '{
{  "name": {
    "operator": "EQUAL",
    "value": ""
  }
}

I get a pretty common error message:

{"$schema":"http://127.0.0.1:8888/schemas/ErrorModel.json","title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"message":"expected value to match exactly one schema but matched none","location":"body.name","value":{"operator":"IN","value":""}}]}

From the source code, this is how Huma currently validates with oneOf.

But in this scenario, we've got a mapping to target schemas by propertyName.

So, it's possible to validate the input against only the target schema if a discriminator.mapping is provided.

Ideal state Ideally, in the above scenario, I'd want to get smth like

"message":"expected 1<=length(value)<100"

instead of

"message":"expected value to match exactly one schema but matched none"

This would help the end user understand why the request is invalid faster.

Questions

Thank you in advance, and have a great weekend.

danielgtaylor commented 3 months ago

@superstas

Do you have any objection against adding this logic to Huma? ( I can send a patch )

This seems like it would be great to add to support better validation error messages when possible. Hopefully this doesn't complicate the code too much :smile:

Maybe there is a way to get what I want without changing the source code?

No, the support for oneOf is fairly basic at this point. I would love some help making the error messages it generates better. This seems like kind of a complex problem unfortunately. At least the discriminator mapping helps simplify it.

superstas commented 3 months ago

This seems like kind of a complex problem unfortunately. At least the discriminator mapping helps simplify it.

Agree.

Overall, sounds great! Thank you.

I'll send a patch when it's ready.