diff --git a/api.go b/api.go index a8f67e74..0972ff3e 100644 --- a/api.go +++ b/api.go @@ -389,6 +389,17 @@ func NewAPI(config Config, a Adapter) API { } ctx.BodyWriter().Write(specJSON) }) + var specJSON30 []byte + a.Handle(&Operation{ + Method: http.MethodGet, + Path: config.OpenAPIPath + "-3.0.json", + }, func(ctx Context) { + ctx.SetHeader("Content-Type", "application/vnd.oai.openapi+json") + if specJSON30 == nil { + specJSON30, _ = newAPI.OpenAPI().Downgrade() + } + ctx.BodyWriter().Write(specJSON30) + }) var specYAML []byte a.Handle(&Operation{ Method: http.MethodGet, @@ -400,6 +411,17 @@ func NewAPI(config Config, a Adapter) API { } ctx.BodyWriter().Write(specYAML) }) + var specYAML30 []byte + a.Handle(&Operation{ + Method: http.MethodGet, + Path: config.OpenAPIPath + "-3.0.yaml", + }, func(ctx Context) { + ctx.SetHeader("Content-Type", "application/vnd.oai.openapi+yaml") + if specYAML30 == nil { + specYAML30, _ = newAPI.OpenAPI().DowngradeYAML() + } + ctx.BodyWriter().Write(specYAML30) + }) } if config.DocsPath != "" { diff --git a/docs/docs/features/openapi-generation.md b/docs/docs/features/openapi-generation.md index 9b369631..c9d62846 100644 --- a/docs/docs/features/openapi-generation.md +++ b/docs/docs/features/openapi-generation.md @@ -8,7 +8,12 @@ description: API configuration options & OpenAPI 3.1 spec generation. Huma generates Open API 3.1 compatible JSON/YAML specs and provides rendered documentation automatically. Every operation that is registered with the API is included in the spec by default. The operation's inputs and outputs are used to generate the request and response parameters / schemas. -The [`huma.Config`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Config) controls where the OpenAPI, docs, and schemas are available. The default config uses `/openapi.json`, `/docs`, and `/schemas` respectively. You can change these to whatever you want, or disable them entirely by leaving them blank. +The [`huma.Config`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Config) controls where the OpenAPI, docs, and schemas are available. The default config uses `/openapi`, `/docs`, and `/schemas` respectively. You can change these to whatever you want, or disable them entirely by leaving them blank. The OpenAPI spec is available in multiple versions (to better support older tools) and in JSON or YAML: + +- OpenAPI 3.1 JSON: [http://localhost:8888/openapi.json](http://localhost:8888/openapi.json) +- OpenAPI 3.1 YAML: [http://localhost:8888/openapi.yaml](http://localhost:8888/openapi.yaml) +- OpenAPI 3.0.3 JSON: [http://localhost:8888/openapi-3.0.json](http://localhost:8888/openapi-3.0.json) +- OpenAPI 3.0.3 YAML: [http://localhost:8888/openapi-3.0.yaml](http://localhost:8888/openapi-3.0.yaml) You may want to customize the generated Open API spec. With Huma v2 you have full access and can modify it as needed in the API configuration or when registering operations. For example, to set up and then use a security scheme: diff --git a/docs/docs/features/request-validation.md b/docs/docs/features/request-validation.md index fa6aaeb9..67ed148c 100644 --- a/docs/docs/features/request-validation.md +++ b/docs/docs/features/request-validation.md @@ -15,7 +15,78 @@ type Person struct { } ``` -The standard `json` tag is supported and can be used to rename a field and mark fields as optional using `omitempty`. Any field tagged with `json:"-"` will be ignored in the schema. +## Field Naming + +The standard `json` tag is supported and can be used to rename a field. Any field tagged with `json:"-"` will be ignored in the schema, as if it did not exist. + +## Optional / Required + +Fields being optional/required is determined automatically but can be overidden as needed using the logic below: + +1. Start with all fields required. +2. If a field has `omitempty`, it is optional. +3. If a field has `required:"false"`, it is optional. +4. If a field has `required:"true"`, it is required. + +Pointers have no effect on optional/required. The same rules apply regardless of whether the struct is being used for request input or response output. Some examples: + +```go +type MyStruct struct { + // The following are all required. + Required1 string `json:"required1"` + Required2 *string `json:"required2"` + Required3 string `json:"required3,omitempty" required:"true"` + + // The following are all optional. + Optional1 string `json:"optional1,omitempty"` + Optional2 *string `json:"optional2,omitempty"` + Optional3 string `json:"optional3" required:"false"` +} +``` + +!!! info "Note" + + Why use `omitempty` for inputs when Go itself only uses the field for marshaling? Imagine a client which is going to send a request to your API - it must still be marshaled into JSON (or a similar format). You can think of your input structs as modeling what an API client would produce as output. + +## Nullable + +In many languages (including Go), there is little to no distinction between an explicit empty value vs. an undefined one. Marking a field as optional as explained above is enough to support either case. Javascript & Typescript are exceptions to this rule, as they have explicit `null` and `undefined` values. + +Huma tries to balance schema simplicity, usability, and broad compatibility with schema correctness and a broad range of language support for end-to-end API tooling. To that end, it supports field nullability to a limited extent, and future changes may modify this default behavior as tools become more compatible with advanced JSON Schema features. + +Fields being nullable is determined automatically but can be overidden as needed using the logic below: + +1. Start with no fields as nullable +2. If a field is a pointer: + 1. To a `boolean`, `integer`, `number`, `string`: it is nullable unless it has `omitempty`. + 2. To an `array`, `object`: it is **not** nullable, due to complexity and bad support for `anyOf`/`oneOf` in many tools. +3. If a field has `nullable:"false"`, it is not nullable +4. If a field has `nullable:"true"`: + 1. To a `boolean`, `integer`, `number`, `string`: it is nullable + 2. To an `array`, `object`: **panic** saying this is not currently supported +5. If a struct has a field `_` with `nullable: true`, the struct is nullable enabling users to opt-in for `object` without the `anyOf`/`oneOf` complication. + +Here are some examples: + +```go title="code.go" +// Make an entire struct (not its fields) nullable. +type MyStruct1 struct { + _ struct{} `nullable:"true"` + Field1 string `json:"field1"` + Field2 string `json:"field2"` +} + +// Make a specific scalar field nullable. This is *not* supported for +// slices, maps, or structs. Structs *must* use the method above. +type MyStruct2 struct { + Field1 *string `json:"field1"` + Field2 string `json:"field2" nullable:"true"` +} +``` + +Nullable types will generate a type array like `"type": ["string", "null"]` which has broad compatibility and is easy to downgrade to OpenAPI 3.0. Also keep in mind you can always provide a [custom schema](./schema-customization.md) if the built-in features aren't exactly what you need. + +## Validation Tags The following additional tags are supported on model fields: diff --git a/docs/docs/terminal/build-sdk.cast b/docs/docs/terminal/build-sdk.cast index 85502050..18c5f76f 100644 --- a/docs/docs/terminal/build-sdk.cast +++ b/docs/docs/terminal/build-sdk.cast @@ -1,325 +1,317 @@ -{"version": 2, "width": 72, "height": 30, "timestamp": 1699556245, "idle_time_limit": 1.0, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}} -[0.010983, "o", "\u001b[?1034h\u001b[39;2m"] -[0.182811, "o", "#"] -[0.223386, "o", " "] -[0.264461, "o", "G"] -[0.30571, "o", "r"] -[0.346828, "o", "a"] -[0.387694, "o", "b"] -[0.428853, "o", " "] -[0.469933, "o", "t"] -[0.511069, "o", "h"] -[0.552057, "o", "e"] -[0.592975, "o", " "] -[0.634167, "o", "O"] -[0.675332, "o", "p"] -[0.716473, "o", "e"] -[0.757614, "o", "n"] -[0.798323, "o", "A"] -[0.838542, "o", "P"] -[0.879556, "o", "I"] -[0.920719, "o", " "] -[0.961994, "o", "s"] -[1.003609, "o", "p"] -[1.044624, "o", "e"] -[1.085003, "o", "c"] -[1.12558, "o", "\r\n\u001b[0m"] -[1.12695, "o", "\u001b[34m$\u001b[0m "] -[1.417704, "o", "r"] -[1.458321, "o", "e"] -[1.499625, "o", "s"] -[1.540708, "o", "t"] -[1.581676, "o", "i"] -[1.623011, "o", "s"] -[1.663918, "o", "h"] -[1.709582, "o", " "] -[1.749711, "o", ":"] -[1.790659, "o", "8"] -[1.831085, "o", "8"] -[1.871737, "o", "8"] -[1.912336, "o", "8"] -[1.95339, "o", "/"] -[1.995067, "o", "o"] -[2.035703, "o", "p"] -[2.076751, "o", "e"] -[2.117951, "o", "n"] -[2.159209, "o", "a"] -[2.200195, "o", "p"] -[2.241568, "o", "i"] -[2.282432, "o", "."] -[2.324502, "o", "y"] -[2.364559, "o", "a"] -[2.405576, "o", "m"] -[2.446708, "o", "l"] -[2.487775, "o", " "] -[2.529078, "o", ">"] -[2.570223, "o", "o"] -[2.611474, "o", "p"] -[2.652658, "o", "e"] -[2.6929, "o", "n"] -[2.733955, "o", "a"] -[2.774967, "o", "p"] -[2.816781, "o", "i"] -[2.857235, "o", "."] -[2.8981, "o", "y"] -[2.939436, "o", "a"] -[2.98039, "o", "m"] -[3.021471, "o", "l"] -[3.06256, "o", "\r\n\u001b[0m"] -[3.100033, "o", ""] -[3.35469, "o", "\r\n\u001b[0m"] -[3.355293, "o", "\u001b[39;2m"] -[3.646675, "o", "#"] -[3.686955, "o", " "] -[3.728102, "o", "I"] -[3.769166, "o", "n"] -[3.810632, "o", "s"] -[3.851447, "o", "t"] -[3.892261, "o", "a"] -[3.934602, "o", "l"] -[3.975197, "o", "l"] -[4.016047, "o", " "] -[4.057358, "o", "t"] -[4.098548, "o", "h"] -[4.139491, "o", "e"] -[4.180757, "o", " "] -[4.221977, "o", "S"] -[4.262998, "o", "D"] -[4.304028, "o", "K"] -[4.345315, "o", " "] -[4.386257, "o", "g"] -[4.427384, "o", "e"] -[4.468462, "o", "n"] -[4.508794, "o", "e"] -[4.550156, "o", "r"] -[4.591069, "o", "a"] -[4.632118, "o", "t"] -[4.673207, "o", "o"] -[4.714375, "o", "r"] -[4.755658, "o", "\r\n\u001b[0m\u001b[34m$\u001b[0m "] -[5.047492, "o", "g"] -[5.088569, "o", "o"] -[5.129714, "o", " "] -[5.170696, "o", "i"] -[5.211939, "o", "n"] -[5.252844, "o", "s"] -[5.29447, "o", "t"] -[5.335275, "o", "a"] -[5.376539, "o", "l"] -[5.417799, "o", "l"] -[5.458449, "o", " "] -[5.499469, "o", "g"] -[5.540539, "o", "i"] -[5.581711, "o", "t"] -[5.62274, "o", "h"] -[5.663845, "o", "u"] -[5.704947, "o", "b"] -[5.745818, "o", "."] -[5.785952, "o", "c"] -[5.827118, "o", "o"] -[5.867447, "o", "m"] -[5.908349, "o", "/"] -[5.949504, "o", "d"] -[5.98989, "o", "e"] -[6.031008, "o", "e"] -[6.072044, "o", "p"] -[6.113157, "o", "m"] -[6.154334, "o", "a"] -[6.195944, "o", "p"] -[6.236735, "o", "/"] -[6.277718, "o", "o"] -[6.31893, "o", "a"] -[6.360079, "o", "p"] -[6.400593, "o", "i"] -[6.442598, "o", "-"] -[6.485829, "o", "c"] -[6.524242, "o", "o"] -[6.565259, "o", "d"] -[6.605773, "o", "e"] -[6.646886, "o", "g"] -[6.687899, "o", "e"] -[6.728794, "o", "n"] -[6.769914, "o", "/"] -[6.810994, "o", "v"] -[6.852315, "o", "2"] -[6.893889, "o", "/"] -[6.934634, "o", "c"] -[6.982797, "o", "m"] -[7.019022, "o", "d"] -[7.059461, "o", "/"] -[7.1004, "o", "o"] -[7.141652, "o", "a"] -[7.182592, "o", "p"] -[7.223627, "o", "i"] -[7.264869, "o", "-"] -[7.305938, "o", "c"] -[7.346982, "o", "o"] -[7.388087, "o", "d"] -[7.428573, "o", "e"] -[7.469809, "o", "g"] -[7.510826, "o", "e"] -[7.552758, "o", "n"] -[7.593685, "o", "@"] -[7.634753, "o", "l"] -[7.675989, "o", "a"] -[7.716942, "o", "t"] -[7.758315, "o", "e"] -[7.801429, "o", "s"] -[7.840529, "o", "t"] -[7.881794, "o", "\r\n\u001b[0m"] -[8.173611, "o", "\r\n\u001b[0m"] -[8.297602, "o", "\u001b[39;2m"] -[8.465231, "o", "#"] -[8.506154, "o", " "] -[8.547462, "o", "G"] -[8.588488, "o", "e"] -[8.629588, "o", "n"] -[8.670755, "o", "e"] -[8.711807, "o", "r"] -[8.75321, "o", "a"] -[8.793989, "o", "t"] -[8.835019, "o", "e"] -[8.875432, "o", " "] -[8.916961, "o", "t"] -[8.95872, "o", "h"] -[9.000329, "o", "e"] -[9.040372, "o", " "] -[9.08171, "o", "S"] -[9.123001, "o", "D"] -[9.162909, "o", "K"] -[9.204074, "o", "\r\n\u001b[0m"] -[9.205059, "o", "\u001b[34m$\u001b[0m "] -[9.495293, "o", "m"] -[9.53693, "o", "k"] -[9.577604, "o", "d"] -[9.618275, "o", "i"] -[9.659382, "o", "r"] -[9.700431, "o", " "] -[9.741486, "o", "-"] -[9.78242, "o", "p"] -[9.823385, "o", " "] -[9.864477, "o", "s"] -[9.904787, "o", "d"] -[9.947161, "o", "k"] -[9.987166, "o", "\r\n\u001b[0m"] -[9.995057, "o", "\u001b[34m$\u001b[0m "] -[10.278661, "o", "o"] -[10.320233, "o", "a"] -[10.360866, "o", "p"] -[10.4018, "o", "i"] -[10.443063, "o", "-"] -[10.48422, "o", "c"] -[10.525501, "o", "o"] -[10.566661, "o", "d"] -[10.607471, "o", "e"] -[10.648578, "o", "g"] -[10.689706, "o", "e"] -[10.730621, "o", "n"] -[10.771652, "o", " "] -[10.812789, "o", "-"] -[10.85395, "o", "g"] -[10.89491, "o", "e"] -[10.936112, "o", "n"] -[10.977108, "o", "e"] -[11.018143, "o", "r"] -[11.059406, "o", "a"] -[11.100339, "o", "t"] -[11.141279, "o", "e"] -[11.182453, "o", " "] -[11.223445, "o", "\""] -[11.264437, "o", "t"] -[11.305706, "o", "y"] -[11.346802, "o", "p"] -[11.387107, "o", "e"] -[11.428123, "o", "s"] -[11.469087, "o", ","] -[11.510094, "o", "c"] -[11.551518, "o", "l"] -[11.591428, "o", "i"] -[11.632293, "o", "e"] -[11.673331, "o", "n"] -[11.713597, "o", "t"] -[11.754008, "o", "\""] -[11.795112, "o", " "] -[11.836253, "o", "-"] -[11.877306, "o", "p"] -[11.918376, "o", "a"] -[11.959505, "o", "c"] -[12.00111, "o", "k"] -[12.041735, "o", "a"] -[12.082805, "o", "g"] -[12.12373, "o", "e"] -[12.165047, "o", " "] -[12.205984, "o", "s"] -[12.24706, "o", "d"] -[12.288853, "o", "k"] -[12.330338, "o", " "] -[12.371544, "o", "o"] -[12.411838, "o", "p"] -[12.453054, "o", "e"] -[12.494181, "o", "n"] -[12.534709, "o", "a"] -[12.575205, "o", "p"] -[12.616349, "o", "i"] -[12.657515, "o", "."] -[12.698453, "o", "y"] -[12.739523, "o", "a"] -[12.780636, "o", "m"] -[12.821786, "o", "l"] -[12.863058, "o", " "] -[12.903615, "o", ">"] -[12.943949, "o", "s"] -[12.984968, "o", "d"] -[13.026202, "o", "k"] -[13.067277, "o", "/"] -[13.108643, "o", "s \r"] -[13.149474, "o", "d"] -[13.190437, "o", "k"] -[13.231459, "o", "."] -[13.272558, "o", "g"] -[13.31315, "o", "o"] -[13.355263, "o", "\r\n\u001b[0m"] -[13.648424, "o", "\r\n\u001b[0m\u001b[39;2m"] -[13.939029, "o", "#"] -[13.980104, "o", " "] -[14.021184, "o", "U"] -[14.062074, "o", "p"] -[14.103196, "o", "d"] -[14.144269, "o", "a"] -[14.184468, "o", "t"] -[14.225601, "o", "e"] -[14.266243, "o", " "] -[14.30735, "o", "p"] -[14.348124, "o", "r"] -[14.389299, "o", "o"] -[14.430231, "o", "j"] -[14.471377, "o", "e"] -[14.512147, "o", "c"] -[14.553129, "o", "t"] -[14.594305, "o", " "] -[14.635581, "o", "d"] -[14.676539, "o", "e"] -[14.717588, "o", "p"] -[14.758302, "o", "e"] -[14.799454, "o", "n"] -[14.840733, "o", "d"] -[14.881584, "o", "e"] -[14.922611, "o", "n"] -[14.963781, "o", "c"] -[15.004717, "o", "i"] -[15.045182, "o", "e"] -[15.086417, "o", "s"] -[15.12745, "o", "\r\n\u001b[0m"] -[15.128229, "o", "\u001b[34m$\u001b[0m "] -[15.419753, "o", "g"] -[15.461016, "o", "o"] -[15.502045, "o", " "] -[15.543154, "o", "m"] -[15.583929, "o", "o"] -[15.625153, "o", "d"] -[15.666528, "o", " "] -[15.707229, "o", "t"] -[15.74857, "o", "i"] -[15.789536, "o", "d"] -[15.829907, "o", "y"] -[15.871665, "o", "\r\n\u001b[0m"] +{"version": 2, "width": 72, "height": 31, "timestamp": 1712334052, "idle_time_limit": 1.0, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}} +[0.011297, "o", "\u001b[?1034h\u001b[39;2m"] +[0.1866, "o", "#"] +[0.227565, "o", " "] +[0.268811, "o", "G"] +[0.309723, "o", "r"] +[0.350789, "o", "a"] +[0.392306, "o", "b"] +[0.434062, "o", " "] +[0.474149, "o", "t"] +[0.515491, "o", "h"] +[0.556839, "o", "e"] +[0.59911, "o", " "] +[0.640647, "o", "O"] +[0.681438, "o", "p"] +[0.722421, "o", "e"] +[0.763244, "o", "n"] +[0.804351, "o", "A"] +[0.845343, "o", "P"] +[0.88704, "o", "I"] +[0.927811, "o", " "] +[0.968996, "o", "s"] +[1.010214, "o", "p"] +[1.051185, "o", "e"] +[1.092577, "o", "c"] +[1.132974, "o", "\r\n\u001b[0m"] +[1.133681, "o", "\u001b[34m$\u001b[0m "] +[1.425616, "o", "g"] +[1.466736, "o", "o"] +[1.507997, "o", " "] +[1.549143, "o", "r"] +[1.590058, "o", "u"] +[1.631806, "o", "n"] +[1.673363, "o", " "] +[1.715213, "o", "."] +[1.756667, "o", " "] +[1.797105, "o", "o"] +[1.838183, "o", "p"] +[1.879222, "o", "e"] +[1.920213, "o", "n"] +[1.961676, "o", "a"] +[2.002061, "o", "p"] +[2.043502, "o", "i"] +[2.084672, "o", " "] +[2.125806, "o", ">"] +[2.16712, "o", "o"] +[2.208239, "o", "p"] +[2.249579, "o", "e"] +[2.290676, "o", "n"] +[2.330783, "o", "a"] +[2.373391, "o", "p"] +[2.413491, "o", "i"] +[2.454515, "o", "."] +[2.495497, "o", "y"] +[2.536464, "o", "a"] +[2.577465, "o", "m"] +[2.618717, "o", "l"] +[2.659505, "o", "\r\n\u001b[0m"] +[2.950738, "o", "\r\n\u001b[0m"] +[3.241962, "o", "#"] +[3.28302, "o", " "] +[3.323337, "o", "I"] +[3.364418, "o", "n"] +[3.4048, "o", "s"] +[3.445838, "o", "t"] +[3.486963, "o", "a"] +[3.527968, "o", "l"] +[3.568776, "o", "l"] +[3.609855, "o", " "] +[3.650984, "o", "t"] +[3.691372, "o", "h"] +[3.732538, "o", "e"] +[3.773637, "o", " "] +[3.813927, "o", "S"] +[3.854969, "o", "D"] +[3.895199, "o", "K"] +[3.936693, "o", " "] +[3.978072, "o", "g"] +[4.018457, "o", "e"] +[4.059418, "o", "n"] +[4.10053, "o", "e"] +[4.141548, "o", "r"] +[4.183471, "o", "a"] +[4.22518, "o", "t"] +[4.266238, "o", "o"] +[4.306801, "o", "r"] +[4.347776, "o", "\r\n\u001b[0m"] +[4.34796, "o", "\u001b[34m$\u001b[0m "] +[4.640948, "o", "g"] +[4.681921, "o", "o"] +[4.723981, "o", " "] +[4.765256, "o", "i"] +[4.806022, "o", "n"] +[4.847104, "o", "s"] +[4.888707, "o", "t"] +[4.928924, "o", "a"] +[4.971204, "o", "l"] +[5.013474, "o", "l"] +[5.054574, "o", " "] +[5.096061, "o", "g"] +[5.13886, "o", "i"] +[5.181142, "o", "t"] +[5.224989, "o", "h"] +[5.26479, "o", "u"] +[5.305663, "o", "b"] +[5.346707, "o", "."] +[5.387739, "o", "c"] +[5.430163, "o", "o"] +[5.469852, "o", "m"] +[5.511307, "o", "/"] +[5.552081, "o", "d"] +[5.593549, "o", "e"] +[5.633598, "o", "e"] +[5.674675, "o", "p"] +[5.716139, "o", "m"] +[5.756742, "o", "a"] +[5.797642, "o", "p"] +[5.838724, "o", "/"] +[5.879685, "o", "o"] +[5.921515, "o", "a"] +[5.96205, "o", "p"] +[6.004104, "o", "i"] +[6.04501, "o", "-"] +[6.087622, "o", "c"] +[6.128012, "o", "o"] +[6.169675, "o", "d"] +[6.210279, "o", "e"] +[6.251375, "o", "g"] +[6.292638, "o", "e"] +[6.333394, "o", "n"] +[6.374407, "o", "/"] +[6.41573, "o", "v"] +[6.457275, "o", "2"] +[6.497613, "o", "/"] +[6.53968, "o", "c"] +[6.580905, "o", "m"] +[6.622861, "o", "d"] +[6.664164, "o", "/"] +[6.705694, "o", "o"] +[6.746571, "o", "a"] +[6.787656, "o", "p"] +[6.828418, "o", "i"] +[6.869439, "o", "-"] +[6.910711, "o", "c"] +[6.951962, "o", "o"] +[6.992868, "o", "d"] +[7.033986, "o", "e"] +[7.075156, "o", "g"] +[7.116389, "o", "e"] +[7.156954, "o", "n"] +[7.198079, "o", "@"] +[7.239313, "o", "l"] +[7.280803, "o", "a"] +[7.319823, "o", "t"] +[7.360844, "o", "e"] +[7.401899, "o", "s"] +[7.443012, "o", "t"] +[7.483945, "o", "\r\n\u001b[0m"] +[7.747107, "o", ""] +[7.775159, "o", "\r\n\u001b[0m"] +[7.775256, "o", "\u001b[39;2m"] +[8.067569, "o", "#"] +[8.108688, "o", " "] +[8.149854, "o", "G"] +[8.191947, "o", "e"] +[8.233586, "o", "n"] +[8.274242, "o", "e"] +[8.314371, "o", "r"] +[8.355391, "o", "a"] +[8.399342, "o", "t"] +[8.44737, "o", "e"] +[8.482771, "o", " "] +[8.525196, "o", "t"] +[8.565471, "o", "h"] +[8.606399, "o", "e"] +[8.658077, "o", " "] +[8.700506, "o", "S"] +[8.74148, "o", "D"] +[8.784425, "o", "K"] +[8.825254, "o", "\r\n\u001b[0m"] +[8.825447, "o", "\u001b[34m$\u001b[0m "] +[9.117644, "o", "m"] +[9.159826, "o", "k"] +[9.200411, "o", "d"] +[9.242631, "o", "i"] +[9.282943, "o", "r"] +[9.323809, "o", " "] +[9.365541, "o", "-"] +[9.407318, "o", "p"] +[9.448798, "o", " "] +[9.489617, "o", "s"] +[9.531629, "o", "d"] +[9.573565, "o", "k"] +[9.614067, "o", "\r\n\u001b[0m"] +[9.622745, "o", "\u001b[34m$\u001b[0m "] +[9.907618, "o", "o"] +[9.949523, "o", "a"] +[9.99045, "o", "p"] +[10.031529, "o", "i"] +[10.07315, "o", "-"] +[10.114614, "o", "c"] +[10.155381, "o", "o"] +[10.196389, "o", "d"] +[10.236506, "o", "e"] +[10.276968, "o", "g"] +[10.317279, "o", "e"] +[10.358512, "o", "n"] +[10.399906, "o", " "] +[10.440822, "o", "-"] +[10.482107, "o", "g"] +[10.525074, "o", "e"] +[10.564678, "o", "n"] +[10.606666, "o", "e"] +[10.647902, "o", "r"] +[10.688393, "o", "a"] +[10.733996, "o", "t"] +[10.771976, "o", "e"] +[10.81197, "o", " "] +[10.852977, "o", "\""] +[10.893962, "o", "t"] +[10.935265, "o", "y"] +[10.976564, "o", "p"] +[11.017699, "o", "e"] +[11.058744, "o", "s"] +[11.099844, "o", ","] +[11.141406, "o", "c"] +[11.181724, "o", "l"] +[11.225624, "o", "i"] +[11.265512, "o", "e"] +[11.306465, "o", "n"] +[11.346933, "o", "t"] +[11.388962, "o", "\""] +[11.430429, "o", " "] +[11.471147, "o", "-"] +[11.511979, "o", "p"] +[11.553087, "o", "a"] +[11.59618, "o", "c"] +[11.63526, "o", "k"] +[11.67668, "o", "a"] +[11.717809, "o", "g"] +[11.75804, "o", "e"] +[11.798713, "o", " "] +[11.839699, "o", "s"] +[11.880731, "o", "d"] +[11.922302, "o", "k"] +[11.965946, "o", " "] +[12.00665, "o", "o"] +[12.048438, "o", "p"] +[12.090208, "o", "e"] +[12.130586, "o", "n"] +[12.172388, "o", "a"] +[12.213048, "o", "p"] +[12.253684, "o", "i"] +[12.295737, "o", "."] +[12.336354, "o", "y"] +[12.377375, "o", "a"] +[12.418543, "o", "m"] +[12.458985, "o", "l"] +[12.500123, "o", " "] +[12.543222, "o", ">"] +[12.583628, "o", "s"] +[12.624317, "o", "d"] +[12.665676, "o", "k"] +[12.707159, "o", "/"] +[12.74909, "o", "s \r"] +[12.789602, "o", "d"] +[12.82996, "o", "k"] +[12.871028, "o", "."] +[12.912889, "o", "g"] +[12.953515, "o", "o"] +[12.993913, "o", "\r\n\u001b[0m"] +[13.215811, "o", ""] +[13.285938, "o", "\r\n\u001b[0m"] +[13.286126, "o", "\u001b[39;2m"] +[13.578513, "o", "#"] +[13.619668, "o", " "] +[13.6609, "o", "U"] +[13.701609, "o", "p"] +[13.742773, "o", "d"] +[13.783368, "o", "a"] +[13.823523, "o", "t"] +[13.864509, "o", "e"] +[13.905567, "o", " "] +[13.948094, "o", "p"] +[13.989684, "o", "r"] +[14.031687, "o", "o"] +[14.072005, "o", "j"] +[14.113528, "o", "e"] +[14.153733, "o", "c"] +[14.197985, "o", "t"] +[14.235842, "o", " "] +[14.277357, "o", "d"] +[14.318025, "o", "e"] +[14.358817, "o", "p"] +[14.399853, "o", "e"] +[14.441436, "o", "n"] +[14.482152, "o", "d"] +[14.526148, "o", "e"] +[14.567643, "o", "n"] +[14.608453, "o", "c"] +[14.649394, "o", "i"] +[14.69213, "o", "e"] +[14.730929, "o", "s"] +[14.772087, "o", "\r\n\u001b[0m"] +[14.772562, "o", "\u001b[34m$\u001b[0m "] +[15.065237, "o", "g"] +[15.107443, "o", "o"] +[15.147817, "o", " "] +[15.190241, "o", "m"] +[15.231428, "o", "o"] +[15.273773, "o", "d"] +[15.3185, "o", " "] +[15.355496, "o", "t"] +[15.396434, "o", "i"] +[15.441212, "o", "d"] +[15.478552, "o", "y"] +[15.524636, "o", "\r\n\u001b[0m"] diff --git a/docs/docs/tutorial/cli-client.md b/docs/docs/tutorial/cli-client.md index 233ae9b4..b384c1e0 100644 --- a/docs/docs/tutorial/cli-client.md +++ b/docs/docs/tutorial/cli-client.md @@ -51,7 +51,7 @@ Also consider setting up [shell command-line completion](https://rest.sh/#/guide ## Configure your API -Next, we need to tell Restish about your API and give it a short name, which we'll call `tutorial`. Do this using the `api configure` command. This only needs to be done one time. +Next, we need to tell Restish about your API and give it a short name, which we'll call `tutorial`. Do this using the `api configure` command. This only needs to be done one time. Make sure your API is running and accessible before continuing, as this pulls the OpenAPI spec from the service. {{ asciinema("../../terminal/restish-config.cast", rows="8") }} diff --git a/docs/docs/tutorial/client-sdks.md b/docs/docs/tutorial/client-sdks.md index fdf2e516..2a8120a0 100644 --- a/docs/docs/tutorial/client-sdks.md +++ b/docs/docs/tutorial/client-sdks.md @@ -6,6 +6,111 @@ description: Level up your API with a generated Go SDK and client that uses it. [Several tools](https://openapi.tools/#sdk) can be used to create SDKs from an OpenAPI spec. Let's use the [`oapi-codegen`](https://github.com/deepmap/oapi-codegen) Go code generator to create a Go SDK, and then build a client using that SDK. +## Add an OpenAPI Command + +First, let's create a command to grab the OpenAPI spec so the service doesn't need to be running and you can generate the SDK as needed (e.g. as part of the API service release process). + +```go title="main.go" linenums="1" hl_lines="67 73 84-94" +package main + +import ( + "context" + "fmt" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/danielgtaylor/huma/v2/humacli" + "github.com/go-chi/chi/v5" +) + +// Options for the CLI. +type Options struct { + Port int `help:"Port to listen on" short:"p" default:"8888"` +} + +// GreetingOutput represents the greeting operation response. +type GreetingOutput struct { + Body struct { + Message string `json:"message" example:"Hello, world!" doc:"Greeting message"` + } +} + +// ReviewInput represents the review operation request. +type ReviewInput struct { + Body struct { + Author string `json:"author" maxLength:"10" doc:"Author of the review"` + Rating int `json:"rating" minimum:"1" maximum:"5" doc:"Rating from 1 to 5"` + Message string `json:"message,omitempty" maxLength:"100" doc:"Review message"` + } +} + +func addRoutes(api huma.API) { + // Register GET /greeting/{name} + huma.Register(api, huma.Operation{ + OperationID: "get-greeting", + Method: http.MethodGet, + Path: "/greeting/{name}", + Summary: "Get a greeting", + Description: "Get a greeting for a person by name.", + Tags: []string{"Greetings"}, + }, func(ctx context.Context, input *struct{ + Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"` + }) (*GreetingOutput, error) { + resp := &GreetingOutput{} + resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name) + return resp, nil + }) + + // Register POST /reviews + huma.Register(api, huma.Operation{ + OperationID: "post-review", + Method: http.MethodPost, + Path: "/reviews", + Summary: "Post a review", + Tags: []string{"Reviews"}, + DefaultStatus: http.StatusCreated, + }, func(ctx context.Context, i *ReviewInput) (*struct{}, error) { + // TODO: save review in data store. + return nil, nil + }) +} + +func main() { + var api huma.API + + // Create a CLI app which takes a port option. + cli := humacli.New(func(hooks humacli.Hooks, options *Options) { + // Create a new router & API + router := chi.NewMux() + api = humachi.New(router, huma.DefaultConfig("My API", "1.0.0")) + + addRoutes(api) + + // Tell the CLI how to start your server. + hooks.OnStart(func() { + fmt.Printf("Starting server on port %d...\n", options.Port) + http.ListenAndServe(fmt.Sprintf(":%d", options.Port), router) + }) + }) + + // Add a command to print the OpenAPI spec. + cli.Root().AddCommand(&cobra.Command{ + Use: "openapi", + Short: "Print the OpenAPI spec", + Run: func(cmd *cobra.Command, args []string) { + // Use downgrade to return OpenAPI 3.0.3 YAML since oapi-codegen doesn't + // support OpenAPI 3.1 fully yet. Use `.YAML()` instead for 3.1. + b, _ := api.OpenAPI().DowngradeYAML() + fmt.Println(string(b)) + }, + }) + + // Run the CLI. When passed no commands, it starts the server. + cli.Run() +} +``` + ## Generate the SDK First, grab the OpenAPI spec. Then install and use the generator to create the SDK. diff --git a/docs/docs/tutorial/your-first-api.md b/docs/docs/tutorial/your-first-api.md index 2eaf0014..5dade938 100644 --- a/docs/docs/tutorial/your-first-api.md +++ b/docs/docs/tutorial/your-first-api.md @@ -169,7 +169,12 @@ Go to [http://localhost:8888/docs](http://localhost:8888/docs) to see the intera Using the panel at the top right of the documentation page you can send a request to the API and see the response. -These docs are generated from the OpenAPI specification, which is available at [http://localhost:8888/openapi.json](http://localhost:8888/openapi.json). You can use this file to generate documentation, client libraries, commandline clients, mock servers, and more. +These docs are generated from the OpenAPI specification. You can use this file to generate documentation, client libraries, commandline clients, mock servers, and more. Two versions are provided by Huma. It is recommended to use OpenAPI 3.1, but OpenAPI 3.0.3 is also available for compatibility with older tools: + +- OpenAPI 3.1 JSON: [http://localhost:8888/openapi.json](http://localhost:8888/openapi.json) +- OpenAPI 3.1 YAML: [http://localhost:8888/openapi.yaml](http://localhost:8888/openapi.yaml) +- OpenAPI 3.0.3 JSON: [http://localhost:8888/openapi-3.0.json](http://localhost:8888/openapi-3.0.json) +- OpenAPI 3.0.3 YAML: [http://localhost:8888/openapi-3.0.yaml](http://localhost:8888/openapi-3.0.yaml) ### Enhancing Documentation diff --git a/docs/terminal/build-sdk.sh b/docs/terminal/build-sdk.sh index f569281b..187b17b5 100644 --- a/docs/terminal/build-sdk.sh +++ b/docs/terminal/build-sdk.sh @@ -1,6 +1,6 @@ #$ wait 250 # Grab the OpenAPI spec -restish :8888/openapi.yaml >openapi.yaml +go run . openapi >openapi.yaml # Install the SDK generator go install github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@latest diff --git a/huma.go b/huma.go index 30caf00c..c439df87 100644 --- a/huma.go +++ b/huma.go @@ -757,7 +757,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if ctf, ok := exampleErr.(ContentTypeFilter); ok { errContentType = ctf.ContentType(errContentType) } - errType := reflect.TypeOf(exampleErr) + errType := deref(reflect.TypeOf(exampleErr)) errSchema := registry.Schema(errType, true, getHint(errType, "", "Error")) for _, code := range op.Errors { op.Responses[strconv.Itoa(code)] = &Response{ diff --git a/huma_test.go b/huma_test.go index 17de81da..cd1be7cf 100644 --- a/huma_test.go +++ b/huma_test.go @@ -1125,7 +1125,14 @@ func TestOpenAPI(t *testing.T) { return resp, nil }) - for _, url := range []string{"/openapi.json", "/openapi.yaml", "/docs", "/schemas/Resp.json"} { + for _, url := range []string{ + "/openapi.json", + "/openapi-3.0.json", + "/openapi.yaml", + "/openapi-3.0.yaml", + "/docs", + "/schemas/RespBody.json", + } { req, _ := http.NewRequest(http.MethodGet, url, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) diff --git a/openapi.go b/openapi.go index b2d49f8e..59a92ffd 100644 --- a/openapi.go +++ b/openapi.go @@ -1518,3 +1518,120 @@ func (o *OpenAPI) YAML() ([]byte, error) { } return buf.Bytes(), err } + +func downgradeSpec(input any) { + switch value := input.(type) { + case map[string]any: + m := value + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + for _, k := range keys { + v := m[k] + if k == "openapi" && v == "3.1.0" { + // Update version. + m[k] = "3.0.3" + continue + } + + if k == "type" { + // OpenAPI 3.1 supports type arrays, which need to be converted. + // This may be lossy, but we want to keep it simple. + // TODO: If we run into more complex cases, split into one-of? + if types, ok := v.([]any); ok { + for _, t := range types { + if t == "null" { + // The "null" type is a nullable field in 3.0. + m["nullable"] = true + } else { + // Last non-null wins. + m["type"] = t + } + } + continue + } + } + + // Exclusive values were bools in 3.0. + if k == "exclusiveMinimum" && reflect.TypeOf(v).Kind() == reflect.Float64 { + m["minimum"] = v + m["exclusiveMinimum"] = true + continue + } + + if k == "exclusiveMaximum" && reflect.TypeOf(v).Kind() == reflect.Float64 { + m["maximum"] = v + m["exclusiveMaximum"] = true + continue + } + + // Provide single example for tools that read it. + if k == "examples" { + if examples, ok := v.([]any); ok { + if len(examples) > 0 { + m["example"] = examples[0] + } + if len(examples) == 1 { + delete(m, k) + } + continue + } + } + + // Base64 / binary uploads + if k == "application/octet-stream" { + if ct, ok := v.(map[string]any); ok && len(ct) == 0 { + m[k] = map[string]any{ + "schema": map[string]any{ + "type": "string", + "format": "binary", + }, + } + } + } + + if k == "contentEncoding" && v == "base64" { + delete(m, k) + m["format"] = "base64" + continue + } + + downgradeSpec(v) + } + case []any: + for _, item := range value { + downgradeSpec(item) + } + } +} + +// Downgrade converts this OpenAPI 3.1 spec to OpenAPI 3.0.3, returning the +// JSON []byte representation of the downgraded spec. This mostly exists +// to provide an alternative spec for tools which are not yet 3.1 compatible. +// +// It reverses the changes documented at: +// https://www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0 +func (o OpenAPI) Downgrade() ([]byte, error) { + b, err := o.MarshalJSON() + if err == nil { + var v any + json.Unmarshal(b, &v) + + downgradeSpec(v) + + b, err = json.Marshal(v) + } + return b, err +} + +// DowngradeYAML converts this OpenAPI 3.1 spec to OpenAPI 3.0.3, returning the +// YAML []byte representation of the downgraded spec. +func (o *OpenAPI) DowngradeYAML() ([]byte, error) { + specJSON, err := o.Downgrade() + buf := bytes.NewBuffer([]byte{}) + if err == nil { + err = yaml.Convert(buf, bytes.NewReader(specJSON)) + } + return buf.Bytes(), err +} diff --git a/openapi_test.go b/openapi_test.go index 189178f4..43223155 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/danielgtaylor/huma/v2" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -168,3 +169,99 @@ x-test: 123 require.Equal(t, expected, string(out)) } + +func TestDowngrade(t *testing.T) { + // Test that we can downgrade a v3 OpenAPI document to v2. + v31 := &huma.OpenAPI{ + OpenAPI: "3.1.0", + Info: &huma.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: map[string]*huma.PathItem{ + "/test": { + Get: &huma.Operation{ + Responses: map[string]*huma.Response{ + "200": { + Description: "OK", + Content: map[string]*huma.MediaType{ + "application/json": { + Schema: &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "test": { + Type: "integer", + ExclusiveMinimum: Ptr(0.0), + ExclusiveMaximum: Ptr(100.0), + Nullable: true, + Examples: []any{100}, + }, + "encoding": { + Type: huma.TypeString, + ContentEncoding: "base64", + }, + }, + }, + }, + "application/octet-stream": {}, + }, + }, + }, + }, + }, + }, + } + + v30, err := v31.Downgrade() + require.NoError(t, err) + + expected := `{ + "openapi": "3.0.3", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/test": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "test": { + "type": "integer", + "nullable": true, + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 100, + "exclusiveMaximum": true, + "example": 100 + }, + "encoding": { + "type": "string", + "format": "base64" + } + } + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + } + }` + + // Check that the downgrade worked as expected. + assert.JSONEq(t, expected, string(v30)) +} diff --git a/registry.go b/registry.go index ab828725..a53a8055 100644 --- a/registry.go +++ b/registry.go @@ -67,6 +67,7 @@ type mapRegistry struct { } func (r *mapRegistry) Schema(t reflect.Type, allowRef bool, hint string) *Schema { + origType := t t = deref(t) alias, ok := r.aliases[t] @@ -86,7 +87,7 @@ func (r *mapRegistry) Schema(t reflect.Type, allowRef bool, hint string) *Schema getsRef = false } - name := r.namer(t, hint) + name := r.namer(origType, hint) if getsRef { if s, ok := r.schemas[name]; ok { @@ -108,7 +109,7 @@ func (r *mapRegistry) Schema(t reflect.Type, allowRef bool, hint string) *Schema r.types[name] = t r.seen[t] = true } - s := SchemaFromType(r, t) + s := SchemaFromType(r, origType) if getsRef { r.schemas[name] = s } diff --git a/schema.go b/schema.go index 98fcc5a8..eaca7277 100644 --- a/schema.go +++ b/schema.go @@ -59,6 +59,7 @@ func deref(t reflect.Type) reflect.Type { // Note that the registry may create references for your types. type Schema struct { Type string `yaml:"type,omitempty"` + Nullable bool `yaml:"-"` Title string `yaml:"title,omitempty"` Description string `yaml:"description,omitempty"` Ref string `yaml:"$ref,omitempty"` @@ -122,8 +123,12 @@ type Schema struct { // MarshalJSON marshals the schema into JSON, respecting the `Extensions` map // to marshal extensions inline. func (s *Schema) MarshalJSON() ([]byte, error) { + var typ any = s.Type + if s.Nullable { + typ = []string{s.Type, "null"} + } return marshalJSON([]jsonFieldInfo{ - {"type", s.Type, omitEmpty}, + {"type", typ, omitEmpty}, {"title", s.Title, omitEmpty}, {"description", s.Description, omitEmpty}, {"$ref", s.Ref, omitEmpty}, @@ -497,6 +502,19 @@ func SchemaFromField(registry Registry, f reflect.StructField, hint string) *Sch } } + if _, ok := f.Tag.Lookup("nullable"); ok { + fs.Nullable = boolTag(f, "nullable") + if fs.Nullable && fs.Ref != "" { + // Nullability is only supported for scalar types for now. Objects are + // much more complicated because the `null` type lives within the object + // definition (requiring multiple copies of the object) or needs to use + // `anyOf` or `not` which is not supported by all code generators, or is + // supported poorly & generates hard-to-use code. This is less than ideal + // but a compromise for now to support some nullability built-in. + panic(fmt.Errorf("nullable is not supported for field '%s' which is type '%s'", f.Name, fs.Ref)) + } + } + fs.Minimum = floatTag(f, "minimum") fs.ExclusiveMinimum = floatTag(f, "exclusiveMinimum") fs.Maximum = floatTag(f, "maximum") @@ -587,17 +605,19 @@ func SchemaFromType(r Registry, t reflect.Type) *Schema { return sp.Schema(r) } + isPointer := t.Kind() == reflect.Pointer + s := Schema{} t = deref(t) // Handle special cases. switch t { case timeType: - return &Schema{Type: TypeString, Format: "date-time"} + return &Schema{Type: TypeString, Nullable: isPointer, Format: "date-time"} case urlType: - return &Schema{Type: TypeString, Format: "uri"} + return &Schema{Type: TypeString, Nullable: isPointer, Format: "uri"} case ipType: - return &Schema{Type: TypeString, Format: "ipv4"} + return &Schema{Type: TypeString, Nullable: isPointer, Format: "ipv4"} case rawMessageType: return &Schema{} } @@ -681,14 +701,18 @@ func SchemaFromType(r Registry, t reflect.Type) *Schema { fieldSet[f.Name] = struct{}{} + // Controls whether the field is required or not. All fields start as + // required, then can be made optional with the `omitempty` JSON tag or it + // can be overridden manually via the `required` tag. + fieldRequired := true + name := f.Name - omit := false if j := f.Tag.Get("json"); j != "" { if n := strings.Split(j, ",")[0]; n != "" { name = n } if strings.Contains(j, "omitempty") { - omit = true + fieldRequired = false } } if name == "-" { @@ -696,6 +720,10 @@ func SchemaFromType(r Registry, t reflect.Type) *Schema { continue } + if _, ok := f.Tag.Lookup("required"); ok { + fieldRequired = boolTag(f, "required") + } + if boolTag(f, "hidden") { // This field is deliberately ignored. It may still exist, but won't // be documented. @@ -710,10 +738,17 @@ func SchemaFromType(r Registry, t reflect.Type) *Schema { if fs != nil { props[name] = fs propNames = append(propNames, name) - if !omit { + + if fieldRequired { required = append(required, name) requiredMap[name] = true } + + // Special case: pointer with omitempty and not manually set to + // nullable, which will never get `null` sent over the wire. + if f.Type.Kind() == reflect.Ptr && strings.Contains(f.Tag.Get("json"), "omitempty") && f.Tag.Get("nullable") != "true" { + fs.Nullable = false + } } } s.Type = TypeObject @@ -743,6 +778,11 @@ func SchemaFromType(r Registry, t reflect.Type) *Schema { if _, ok = f.Tag.Lookup("additionalProperties"); ok { additionalProps = boolTag(f, "additionalProperties") } + + if _, ok := f.Tag.Lookup("nullable"); ok { + // Allow overriding nullability per struct. + s.Nullable = boolTag(f, "nullable") + } } s.AdditionalProperties = additionalProps @@ -758,5 +798,12 @@ func SchemaFromType(r Registry, t reflect.Type) *Schema { return nil } + switch s.Type { + case TypeBoolean, TypeInteger, TypeNumber, TypeString: + // Scalar types which are pointers are nullable by default. This can be + // overidden via the `nullable:"false"` field tag in structs. + s.Nullable = isPointer + } + return &s } diff --git a/schema_test.go b/schema_test.go index 1087c845..ea64b818 100644 --- a/schema_test.go +++ b/schema_test.go @@ -79,6 +79,11 @@ func TestSchema(t *testing.T) { input: true, expected: `{"type": "boolean"}`, }, + { + name: "bool-pointer", + input: Ptr(true), + expected: `{"type": ["boolean", "null"]}`, + }, { name: "int", input: 1, @@ -129,9 +134,14 @@ func TestSchema(t *testing.T) { input: time.Now(), expected: `{"type": "string", "format": "date-time"}`, }, + { + name: "time-pointer", + input: Ptr(time.Now()), + expected: `{"type": ["string", "null"], "format": "date-time"}`, + }, { name: "url", - input: &url.URL{}, + input: url.URL{}, expected: `{"type": "string", "format": "uri"}`, }, { @@ -613,18 +623,54 @@ func TestSchema(t *testing.T) { expected: `{ "type": "object", "additionalProperties": false, - "required": ["int", "str"], "properties": { "int": { - "type": "integer", + "type": ["integer", "null"], "format": "int64", "examples": [123] }, "str": { - "type": "string", + "type": ["string", "null"], "examples": ["foo"] } - } + }, + "required": ["int", "str"] + }`, + }, + { + name: "field-nullable", + input: struct { + Int *int64 `json:"int" nullable:"true"` + }{}, + expected: `{ + "type": "object", + "additionalProperties": false, + "properties": { + "int": { + "type": ["integer", "null"], + "format": "int64" + } + }, + "required": ["int"] + }`, + }, + { + name: "field-nullable-struct", + input: struct { + Field struct { + _ struct{} `json:"-" nullable:"true"` + Foo string `json:"foo"` + } `json:"field"` + }{}, + expected: `{ + "type": "object", + "additionalProperties": false, + "properties": { + "field": { + "$ref": "#/components/schemas/FieldStruct" + } + }, + "required": ["field"] }`, }, { @@ -656,7 +702,7 @@ func TestSchema(t *testing.T) { }, "type":"array"} }, - "required":["slice","array","map","byValue","byRef"], + "required":["slice","array","map","byValue", "byRef"], "type":"object" }`, }, @@ -758,6 +804,15 @@ func TestSchema(t *testing.T) { }{}, panics: `dependent field 'missing1' for field 'value1' does not exist; dependent field 'missing2' for field 'value1' does not exist; dependent field 'missing2' for field 'value2' does not exist`, }, + { + name: "panic-nullable-struct", + input: struct { + Value *struct { + Foo string `json:"foo"` + } `json:"value" nullable:"true"` + }{}, + panics: `nullable is not supported for field 'Value' which is type '#/components/schemas/ValueStruct'`, + }, } for _, c := range cases { diff --git a/validate.go b/validate.go index 35bf852b..9c404ac9 100644 --- a/validate.go +++ b/validate.go @@ -337,6 +337,10 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, } } + if s.Nullable && v == nil { + return + } + switch s.Type { case TypeBoolean: if _, ok := v.(bool); !ok { @@ -559,7 +563,7 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, continue } - if m[k] == nil { + if _, ok := m[k]; !ok { if !s.requiredMap[k] { continue } @@ -572,6 +576,12 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, continue } + if m[k] == nil && (!s.requiredMap[k] || s.Nullable) { + // This is a non-required field which is null, or a nullable field set + // to null, so ignore it. + continue + } + if m[k] != nil && s.DependentRequired[k] != nil { for _, dependent := range s.DependentRequired[k] { if m[dependent] != nil { @@ -647,7 +657,7 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m continue } - if m[k] == nil { + if _, ok := m[k]; !ok { if !s.requiredMap[k] { continue } @@ -660,6 +670,12 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m continue } + if m[k] == nil && (!s.requiredMap[k] || s.Nullable) { + // This is a non-required field which is null, or a nullable field set + // to null, so ignore it. + continue + } + if m[k] != nil && s.DependentRequired[k] != nil { for _, dependent := range s.DependentRequired[k] { if m[dependent] != nil { diff --git a/validate_test.go b/validate_test.go index 49c6eb4e..1a87e54e 100644 --- a/validate_test.go +++ b/validate_test.go @@ -1025,6 +1025,20 @@ var validateTests = []struct { }{}), input: map[string]any{}, }, + { + name: "optional null success", + typ: reflect.TypeOf(struct { + Value *string `json:"value,omitempty" minLength:"1"` + }{}), + input: map[string]any{"value": nil}, + }, + { + name: "optional any null success", + typ: reflect.TypeOf(struct { + Value *string `json:"value,omitempty" minLength:"1"` + }{}), + input: map[any]any{"value": nil}, + }, { name: "optional fail", typ: reflect.TypeOf(struct { @@ -1220,6 +1234,26 @@ var validateTests = []struct { input: 5, errs: []string{"expected value to not match schema"}, }, + { + name: "nullable success", + s: &huma.Schema{Type: huma.TypeNumber, Nullable: true}, + input: nil, + }, + { + name: "pointer required field success", + typ: reflect.TypeOf(struct { + Field *int `json:"field" required:"true" nullable:"true"` + }{}), + input: map[string]any{"field": nil}, + }, + { + name: "pointer required field fail", + typ: reflect.TypeOf(struct { + Field *int `json:"field" required:"true" nullable:"true"` + }{}), + input: map[string]any{}, + errs: []string{"expected required property field to be present"}, + }, } func TestValidate(t *testing.T) {