From 09be2360ca660f50a93989407968fca5c6d9d25e Mon Sep 17 00:00:00 2001 From: Steve Lessard Date: Tue, 22 Mar 2022 17:43:11 -0700 Subject: [PATCH 1/5] Add support for formats added to recent JSON schema specification drafts --- openapi3/schema.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openapi3/schema.go b/openapi3/schema.go index 1b500b7f3..329a0903c 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -673,12 +673,15 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) (err error) switch format { // Supported by OpenAPIv3.0.1: case "byte", "binary", "date", "date-time", "password": - // In JSON Draft-07 (not validated yet though): - case "regex": - case "time", "email", "idn-email": - case "hostname", "idn-hostname", "ipv4", "ipv6": - case "uri", "uri-reference", "iri", "iri-reference", "uri-template": - case "json-pointer", "relative-json-pointer": + // In JSON Draft-07 (not validated yet though): + // https://json-schema.org/draft-07/json-schema-release-notes.html#formats + case "iri", "iri-reference", "uri-template", "idn-email", "idn-hostname": + case "json-pointer", "relative-json-pointer", "regex", "time": + // In JSON Draft 2019-09 (not validated yet though): + // https://json-schema.org/draft/2019-09/release-notes.html#format-vocabulary + case "duration", "uuid": + // Defined in some other specification + case "email", "hostname", "ipv4", "ipv6", "uri", "uri-reference": default: // Try to check for custom defined formats if _, ok := SchemaStringFormats[format]; !ok && !SchemaFormatValidationDisabled { From 543f56e4207d2c1683c453e8b785cda47be83e31 Mon Sep 17 00:00:00 2001 From: Steve Lessard Date: Tue, 22 Mar 2022 17:48:09 -0700 Subject: [PATCH 2/5] Update comment to OpenAPI v3.0.3 and add link to the specification document --- openapi3/schema.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openapi3/schema.go b/openapi3/schema.go index 329a0903c..ee21bc21b 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -671,7 +671,8 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) (err error) case TypeString: if format := schema.Format; len(format) > 0 { switch format { - // Supported by OpenAPIv3.0.1: + // Supported by OpenAPIv3.0.3: + // https://spec.openapis.org/oas/v3.0.3 case "byte", "binary", "date", "date-time", "password": // In JSON Draft-07 (not validated yet though): // https://json-schema.org/draft-07/json-schema-release-notes.html#formats From 85a4de6e97eb377997dfb619a771c8c7091256d9 Mon Sep 17 00:00:00 2001 From: Steve Lessard Date: Thu, 31 Mar 2022 13:28:54 -0700 Subject: [PATCH 3/5] Fix issue https://github.com/getkin/kin-openapi/issues/367 --- routers/gorillamux/router.go | 16 +++++- routers/gorillamux/router_test.go | 86 ++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/routers/gorillamux/router.go b/routers/gorillamux/router.go index 83bbf829e..c2381c6be 100644 --- a/routers/gorillamux/router.go +++ b/routers/gorillamux/router.go @@ -7,6 +7,7 @@ package gorillamux import ( + "fmt" "net/http" "net/url" "sort" @@ -36,7 +37,7 @@ func NewRouter(doc *openapi3.T) (routers.Router, error) { } servers := make([]srv, 0, len(doc.Servers)) for _, server := range doc.Servers { - serverURL := server.URL + serverURL := resolveServerURL(server) var schemes []string var u *url.URL var err error @@ -101,6 +102,19 @@ func NewRouter(doc *openapi3.T) (routers.Router, error) { return r, nil } +// resolveServerURL Resolves variables that may be in the server.URL property +// Each variable that is declared in the OpenAPI document will be replaced +// with its default value. See more info on server variables at +// https://spec.openapis.org/oas/v3.0.3#server-variable-object +func resolveServerURL(server *openapi3.Server) string { + var resolvedValue = server.URL + for key, element := range server.Variables { + // TODO: are OpenAPI Server Variable names case-sensitive? + resolvedValue = strings.Replace(resolvedValue, fmt.Sprintf("{%s}", key), element.Default, -1) + } + return resolvedValue +} + // FindRoute extracts the route and parameters of an http.Request func (r *Router) FindRoute(req *http.Request) (*routers.Route, map[string]string, error) { for i, muxRoute := range r.muxes { diff --git a/routers/gorillamux/router_test.go b/routers/gorillamux/router_test.go index 90f5c3dba..38e0c93a6 100644 --- a/routers/gorillamux/router_test.go +++ b/routers/gorillamux/router_test.go @@ -215,7 +215,12 @@ func TestServerPath(t *testing.T) { _, err = NewRouter(&openapi3.T{Servers: openapi3.Servers{ server, &openapi3.Server{URL: "http://example.com/"}, - &openapi3.Server{URL: "http://example.com/path"}}, + &openapi3.Server{URL: "http://example.com/path"}, + newServerWithVariables( + "{scheme}://localhost", + map[string]string{ + "scheme": "https", + })}, }) require.NoError(t, err) } @@ -242,3 +247,82 @@ func TestRelativeURL(t *testing.T) { require.NoError(t, err) require.Equal(t, "/hello", route.Path) } + +func Test_resolveServerURL(t *testing.T) { + type args struct { + server *openapi3.Server + } + tests := []struct { + name string + args args + want string + }{ + { + name: "Test without any variables at all", + args: args{ + server: newServerWithVariables( + "http://example.com", + nil), + }, + want: "http://example.com", + }, + { + name: "Test entire URL is a single variable", + args: args{ + server: newServerWithVariables( + "{server}", + map[string]string{"server": "/"}), + }, + want: "/", + }, + { + name: "Test with variable scheme", + args: args{ + server: newServerWithVariables( + "{scheme}://localhost", + map[string]string{"scheme": "https"}), + }, + want: "https://localhost", + }, + { + name: "Test variable scheme, port, and root-path", + args: args{ + server: newServerWithVariables( + "{scheme}://localhost:{port}/{root-path}", + map[string]string{"scheme": "https", "port": "8080", "root-path": "api"}), + }, + want: "https://localhost:8080/api", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveServerURL(tt.args.server); got != tt.want { + t.Errorf("resolveServerURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func newServerWithVariables(url string, variables map[string]string) *openapi3.Server { + var serverVariables = map[string]*openapi3.ServerVariable{} + + for key, value := range variables { + serverVariables[key] = newServerVariable(value) + } + + return &openapi3.Server{ + ExtensionProps: openapi3.ExtensionProps{}, + URL: url, + Description: "", + Variables: serverVariables, + } +} + +func newServerVariable(defaultValue string) *openapi3.ServerVariable { + return &openapi3.ServerVariable{ + ExtensionProps: openapi3.ExtensionProps{}, + Enum: nil, + Default: defaultValue, + Description: "", + } +} From 51a979819838fc2262cda271607f4812ffa352da Mon Sep 17 00:00:00 2001 From: Steve Lessard Date: Fri, 9 Dec 2022 23:29:07 -0800 Subject: [PATCH 4/5] Add an identifier for the schema in a ResponseError's Reason field With this change it is possible to identify which schema isn't matched by the response. This is especially helpful if SchemaError has an error message customizer function that is cutting out the very, very verbose schema dump for each invalid field in the response --- openapi3filter/options_test.go | 2 +- openapi3filter/validate_request.go | 3 ++- openapi3filter/validate_response.go | 21 ++++++++++++++++++++- openapi3filter/validation_error_test.go | 12 ++++++------ routers/gorillamux/example_test.go | 2 +- routers/legacy/validate_request_test.go | 2 +- 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/openapi3filter/options_test.go b/openapi3filter/options_test.go index a95b6bb96..36a0677a6 100644 --- a/openapi3filter/options_test.go +++ b/openapi3filter/options_test.go @@ -78,5 +78,5 @@ paths: fmt.Println(err.Error()) - // Output: request body has an error: doesn't match the schema: field "Some field" must be an integer + // Output: request body has an error: doesn't match the schema : field "Some field" must be an integer } diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go index 4acb9ff1f..87b8d6df1 100644 --- a/openapi3filter/validate_request.go +++ b/openapi3filter/validate_request.go @@ -273,10 +273,11 @@ func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, req // Validate JSON with the schema if err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil { + schemaId := getSchemaIdentifier(contentType.Schema) return &RequestError{ Input: input, RequestBody: requestBody, - Reason: "doesn't match the schema", + Reason: fmt.Sprintf("doesn't match the schema %s", schemaId), Err: err, } } diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index abcbb4e9d..db4250aad 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -8,6 +8,7 @@ import ( "io/ioutil" "net/http" "sort" + "strings" "github.com/getkin/kin-openapi/openapi3" ) @@ -159,11 +160,29 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error // Validate data with the schema. if err := contentType.Schema.Value.VisitJSON(value, append(opts, openapi3.VisitAsResponse())...); err != nil { + schemaId := getSchemaIdentifier(contentType.Schema) return &ResponseError{ Input: input, - Reason: "response body doesn't match the schema", + Reason: fmt.Sprintf("response body doesn't match the schema %s", schemaId), Err: err, + // TODO: add an error message customizer function similar to SchemaError } } return nil } + +func getSchemaIdentifier(schema *openapi3.SchemaRef) string { + var id string + + if schema != nil { + id = schema.Ref + } + if strings.TrimSpace(id) == "" && schema.Value != nil { + id = schema.Value.Title + } + if strings.TrimSpace(id) == "" && schema.Value != nil { + id = schema.Value.Description + } + + return id +} diff --git a/openapi3filter/validation_error_test.go b/openapi3filter/validation_error_test.go index 6fee1355d..8b1c130cd 100644 --- a/openapi3filter/validation_error_test.go +++ b/openapi3filter/validation_error_test.go @@ -322,7 +322,7 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"status":"watdis"}`)), }, - wantErrReason: "doesn't match the schema", + wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", wantErrSchemaReason: "value \"watdis\" is not one of the allowed values", wantErrSchemaValue: "watdis", wantErrSchemaPath: "/status", @@ -336,7 +336,7 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama"}`)), }, - wantErrReason: "doesn't match the schema", + wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", wantErrSchemaReason: `property "photoUrls" is missing`, wantErrSchemaValue: map[string]string{"name": "Bahama"}, wantErrSchemaPath: "/photoUrls", @@ -350,7 +350,7 @@ func getValidationTests(t *testing.T) []*validationTest { r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":[],"category":{}}`)), }, - wantErrReason: "doesn't match the schema", + wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", wantErrSchemaReason: `property "name" is missing`, wantErrSchemaValue: map[string]string{}, wantErrSchemaPath: "/category/name", @@ -364,7 +364,7 @@ func getValidationTests(t *testing.T) []*validationTest { r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":[],"category":{"tags": [{}]}}`)), }, - wantErrReason: "doesn't match the schema", + wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", wantErrSchemaReason: `property "name" is missing`, wantErrSchemaValue: map[string]string{}, wantErrSchemaPath: "/category/tags/0/name", @@ -378,7 +378,7 @@ func getValidationTests(t *testing.T) []*validationTest { r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":"http://cat"}`)), }, - wantErrReason: "doesn't match the schema", + wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", wantErrSchemaReason: "field must be set to array or not be present", wantErrSchemaPath: "/photoUrls", wantErrSchemaValue: "string", @@ -393,7 +393,7 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet2", bytes.NewBufferString(`{"name":"Bahama"}`)), }, - wantErrReason: "doesn't match the schema", + wantErrReason: "doesn't match the schema ", wantErrSchemaPath: "/", wantErrSchemaValue: map[string]string{"name": "Bahama"}, wantErrSchemaOriginReason: `property "photoUrls" is missing`, diff --git a/routers/gorillamux/example_test.go b/routers/gorillamux/example_test.go index 2ca3225a5..e43d0691f 100644 --- a/routers/gorillamux/example_test.go +++ b/routers/gorillamux/example_test.go @@ -53,7 +53,7 @@ func Example() { err = openapi3filter.ValidateResponse(ctx, responseValidationInput) fmt.Println(err) // Output: - // response body doesn't match the schema: field must be set to string or not be present + // response body doesn't match the schema pathref.openapi.yml#/components/schemas/TestSchema: field must be set to string or not be present // Schema: // { // "type": "string" diff --git a/routers/legacy/validate_request_test.go b/routers/legacy/validate_request_test.go index 5b9518c78..48090ba60 100644 --- a/routers/legacy/validate_request_test.go +++ b/routers/legacy/validate_request_test.go @@ -107,6 +107,6 @@ func Example() { fmt.Println(err) } // Output: - // request body has an error: doesn't match the schema: input matches more than one oneOf schemas + // request body has an error: doesn't match the schema : input matches more than one oneOf schemas } From bf9e5af45f57380b0dfba5e0acd5030d70d9da0c Mon Sep 17 00:00:00 2001 From: Steve Lessard Date: Tue, 13 Dec 2022 10:39:05 -0800 Subject: [PATCH 5/5] Add PR comments - refactor how a schema's identifier is resolved. - When including the schema's identifier in an error message add a leading space when the identifier is not an empty string --- openapi3filter/options_test.go | 2 +- openapi3filter/validate_request.go | 3 ++- openapi3filter/validate_response.go | 23 +++++++++++++++-------- openapi3filter/validation_error_test.go | 12 ++++++------ routers/gorillamux/example_test.go | 2 +- routers/legacy/validate_request_test.go | 2 +- 6 files changed, 26 insertions(+), 18 deletions(-) diff --git a/openapi3filter/options_test.go b/openapi3filter/options_test.go index 36a0677a6..fd19329ff 100644 --- a/openapi3filter/options_test.go +++ b/openapi3filter/options_test.go @@ -78,5 +78,5 @@ paths: fmt.Println(err.Error()) - // Output: request body has an error: doesn't match the schema : field "Some field" must be an integer + // Output: request body has an error: doesn't match schema: field "Some field" must be an integer } diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go index 87b8d6df1..f4d7f2fe4 100644 --- a/openapi3filter/validate_request.go +++ b/openapi3filter/validate_request.go @@ -274,10 +274,11 @@ func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, req // Validate JSON with the schema if err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil { schemaId := getSchemaIdentifier(contentType.Schema) + schemaId = prependSpaceIfNeeded(schemaId) return &RequestError{ Input: input, RequestBody: requestBody, - Reason: fmt.Sprintf("doesn't match the schema %s", schemaId), + Reason: fmt.Sprintf("doesn't match schema%s", schemaId), Err: err, } } diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index db4250aad..27bef82d3 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -161,28 +161,35 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error // Validate data with the schema. if err := contentType.Schema.Value.VisitJSON(value, append(opts, openapi3.VisitAsResponse())...); err != nil { schemaId := getSchemaIdentifier(contentType.Schema) + schemaId = prependSpaceIfNeeded(schemaId) return &ResponseError{ Input: input, - Reason: fmt.Sprintf("response body doesn't match the schema %s", schemaId), + Reason: fmt.Sprintf("response body doesn't match schema%s", schemaId), Err: err, - // TODO: add an error message customizer function similar to SchemaError } } return nil } +// getSchemaIdentifier gets something by which a schema could be identified. +// A schema by itself doesn't have a true identity field. This function makes +// a best effort to get a value that can fill that void. func getSchemaIdentifier(schema *openapi3.SchemaRef) string { var id string if schema != nil { - id = schema.Ref + id = strings.TrimSpace(schema.Ref) } - if strings.TrimSpace(id) == "" && schema.Value != nil { - id = schema.Value.Title - } - if strings.TrimSpace(id) == "" && schema.Value != nil { - id = schema.Value.Description + if id == "" && schema.Value != nil { + id = strings.TrimSpace(schema.Value.Title) } return id } + +func prependSpaceIfNeeded(value string) string { + if len(value) > 0 { + value = " " + value + } + return value +} diff --git a/openapi3filter/validation_error_test.go b/openapi3filter/validation_error_test.go index 8b1c130cd..b84d8bdb6 100644 --- a/openapi3filter/validation_error_test.go +++ b/openapi3filter/validation_error_test.go @@ -322,7 +322,7 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"status":"watdis"}`)), }, - wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", wantErrSchemaReason: "value \"watdis\" is not one of the allowed values", wantErrSchemaValue: "watdis", wantErrSchemaPath: "/status", @@ -336,7 +336,7 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama"}`)), }, - wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", wantErrSchemaReason: `property "photoUrls" is missing`, wantErrSchemaValue: map[string]string{"name": "Bahama"}, wantErrSchemaPath: "/photoUrls", @@ -350,7 +350,7 @@ func getValidationTests(t *testing.T) []*validationTest { r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":[],"category":{}}`)), }, - wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", wantErrSchemaReason: `property "name" is missing`, wantErrSchemaValue: map[string]string{}, wantErrSchemaPath: "/category/name", @@ -364,7 +364,7 @@ func getValidationTests(t *testing.T) []*validationTest { r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":[],"category":{"tags": [{}]}}`)), }, - wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", wantErrSchemaReason: `property "name" is missing`, wantErrSchemaValue: map[string]string{}, wantErrSchemaPath: "/category/tags/0/name", @@ -378,7 +378,7 @@ func getValidationTests(t *testing.T) []*validationTest { r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":"http://cat"}`)), }, - wantErrReason: "doesn't match the schema #/components/schemas/PetWithRequired", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", wantErrSchemaReason: "field must be set to array or not be present", wantErrSchemaPath: "/photoUrls", wantErrSchemaValue: "string", @@ -393,7 +393,7 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet2", bytes.NewBufferString(`{"name":"Bahama"}`)), }, - wantErrReason: "doesn't match the schema ", + wantErrReason: "doesn't match schema", wantErrSchemaPath: "/", wantErrSchemaValue: map[string]string{"name": "Bahama"}, wantErrSchemaOriginReason: `property "photoUrls" is missing`, diff --git a/routers/gorillamux/example_test.go b/routers/gorillamux/example_test.go index e43d0691f..54058cde2 100644 --- a/routers/gorillamux/example_test.go +++ b/routers/gorillamux/example_test.go @@ -53,7 +53,7 @@ func Example() { err = openapi3filter.ValidateResponse(ctx, responseValidationInput) fmt.Println(err) // Output: - // response body doesn't match the schema pathref.openapi.yml#/components/schemas/TestSchema: field must be set to string or not be present + // response body doesn't match schema pathref.openapi.yml#/components/schemas/TestSchema: field must be set to string or not be present // Schema: // { // "type": "string" diff --git a/routers/legacy/validate_request_test.go b/routers/legacy/validate_request_test.go index 48090ba60..9c15ed44a 100644 --- a/routers/legacy/validate_request_test.go +++ b/routers/legacy/validate_request_test.go @@ -107,6 +107,6 @@ func Example() { fmt.Println(err) } // Output: - // request body has an error: doesn't match the schema : input matches more than one oneOf schemas + // request body has an error: doesn't match schema: input matches more than one oneOf schemas }