huma icon indicating copy to clipboard operation
huma copied to clipboard

Support for `oneOf`, `anyOf`, `allOf`, `not` via struct tags

Open dennisgsmith opened this issue 8 months ago • 1 comments

First off, I love this project! Thank you for all of the incredible work you do.

I really enjoy just being able to define request inputs and outputs via struct -- but it seems that currently the only way to use oneOf, anyOf, allOf, or not are by passing structs to huma.Operation.RequestBody or huma.Operation.Response. I think it would be useful to have struct tags to more conveniently define this.

For example, here's how I'm currently building oneOf tagged union requests (minus the oneOf struct tag). This works well enough, but would love to have a way to do achieve this via struct tags for the input and output structs. I'd love to hear other ideas and feedback, or if there's already an existing way to do this I'm just not aware of.

// Request body DTO
type Example struct {
	Type string `json:"type" enum:"foo,bar,baz"`
	RawOpts json.RawMessage `json:"opts"`
	Opts struct {
		Foo Foo
		Bar Bar
		Baz Baz
	} `json:"-" oneOf:"opts"` // oneOf tag value refers to json tag value above
	// could be used to generate what would otherwise be defined manually in huma.Schema
}

// It might be more appropriate to do this with huma.Resolve? but this works too
// I chose this approach because the same could potentially be done for
// response bodies via MarshalJSON
func (x *Example) UnmarshalJSON(b []byte) error {
	type alias Example
	tmp := &struct{ *alias }{alias: (*alias)(x)}
	if err := json.Unmarshal(b, &tmp); err != nil {
		return err
	}
	*x = Example(*tmp.alias)
	var err error
	switch x.Type {
	case "foo":
		err = json.Unmarshal(x.RawOpts, &x.Opts.Foo)
	case "bar":
		err = json.Unmarshal(x.RawOpts, &x.Opts.Bar)
	case "baz":
		err = json.Unmarshal(x.RawOpts, &x.Opts.Baz)
	default:
		err = errors.New("invalid example type")
	}
	return err
}

// From what I can tell, all of the code below can be derived from the
// input struct body via tags and reflection
func (h *ApiHandler) RegisterExample(api huma.API) {
	registry := api.OpenAPI().Components.Schemas

	optsSchema := &huma.Schema{
		OneOf: []*huma.Schema{
			registry.Schema(reflect.TypeFor[Foo](), true, ""),
			registry.Schema(reflect.TypeFor[Bar](), true, ""),
			registry.Schema(reflect.TypeFor[Baz](), true, ""),
		},
		Nullable: false,
	}
	exampleSchema := registry.SchemaFromRef(registry.Schema(reflect.TypeFor[Example](), true, "").Ref)
	exampleSchema.Properties["opts"] = optsSchema
	exampleSchema.PrecomputeMessages()

	exampleRequestBody := &huma.RequestBody{
		Required: true,
		Content: map[string]*huma.MediaType{"application/json": {
			Schema: exampleSchema,
		}},
	}

	// huma.Register ...
}

It would be great to hear your thoughts on this. Thanks again!

dennisgsmith avatar Mar 14 '25 02:03 dennisgsmith

@dennisgsmith thanks! This looks a lot like an OpenAPI 3 discriminated union: https://swagger.io/docs/specification/v3_0/data-models/inheritance-and-polymorphism/#discriminator. I'm not opposed to adding such a feature (we already support manually creating such a schema today, but no struct tags). I've shied away from the complexity of supporting this via field tags or custom types but am open to ideas for how it should work.

danielgtaylor avatar Mar 15 '25 19:03 danielgtaylor

Is there an example of how to have a union as an output?

Ok, it's probably https://github.com/danielgtaylor/huma/tree/main/examples/oneof-response

VelorumS avatar Jul 30 '25 08:07 VelorumS