diff --git a/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st b/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st index 2d92a32..efe5a7c 100644 --- a/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st +++ b/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st @@ -5,16 +5,58 @@ Class { #package : 'OpenAPI-Core-Tests' } +{ #category : 'tests' } +OADocumentValidatorTests >> testApiKeySecuritySchemeIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"securitySchemes":{"apiKeyAuth":{"type":"apiKey","name":"X-API-Key","in":"header"}}}}') +] + { #category : 'tests' } OADocumentValidatorTests >> testBadPathsTypeIsInvalid [ self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":"nope"}') ] +{ #category : 'tests' } +OADocumentValidatorTests >> testComponentSchemaAdditionalPropertiesBooleanFalseIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"schemas":{"Foo":{"type":"object","additionalProperties":false}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testComponentSchemaAdditionalPropertiesInvalidTypeIsInvalid [ + "additionalProperties must be a Schema, a Reference, or a boolean - a plain string is none of those." + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"schemas":{"Foo":{"type":"object","additionalProperties":"nope"}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testComponentSchemaAdditionalPropertiesSchemaObjectIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"schemas":{"Foo":{"type":"object","additionalProperties":{"type":"string"}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testCookieParameterIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"c","in":"cookie","schema":{"type":"string"}}],"responses":{"200":{"description":"ok"}}}}}}') +] + { #category : 'tests' } OADocumentValidatorTests >> testExplicitVersion30 [ self shouldnt: [ OADocumentValidator validateDocument: self validMinimalDocument version: '3.0.3' ] raise: Error ] +{ #category : 'tests' } +OADocumentValidatorTests >> testHeaderParameterIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"h","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testHttpBearerSecuritySchemeIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testHttpSchemeWithBearerFormatOnNonBearerSchemeIsInvalid [ + "bearerFormat is only meaningful (and only allowed by the meta-schema) when scheme:bearer." + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"securitySchemes":{"basicAuth":{"type":"http","scheme":"basic","bearerFormat":"JWT"}}}}') +] + { #category : 'tests' } OADocumentValidatorTests >> testMissingInfoIsInvalid [ self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","paths":{}}') @@ -30,6 +72,88 @@ OADocumentValidatorTests >> testMissingPathsIsInvalid [ self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"}}') ] +{ #category : 'tests' } +OADocumentValidatorTests >> testOAuth2SecuritySchemeMissingFlowsIsInvalid [ + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"securitySchemes":{"oauth":{"type":"oauth2"}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testParameterWithBothExampleAndExamplesIsInvalid [ + "ExampleXORExamples: example and examples are mutually exclusive." + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"q","in":"query","schema":{"type":"string"},"example":"a","examples":{"x":{"value":"a"}}}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testParameterWithBothSchemaAndContentIsInvalid [ + "SchemaXORContent: schema and content are mutually exclusive, exactly one is required." + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"q","in":"query","schema":{"type":"string"},"content":{"application/json":{}}}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testParameterWithContentAndStyleIsInvalid [ + "When content is used instead of schema, style/explode/allowReserved/example/examples are disallowed." + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"q","in":"query","content":{"application/json":{}},"style":"form"}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testParameterWithNeitherSchemaNorContentIsInvalid [ + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"q","in":"query"}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testParameterWithOnlyContentIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"q","in":"query","content":{"application/json":{}}}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testParameterWithOnlyExampleIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"q","in":"query","schema":{"type":"string"},"example":"a"}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testParameterWithSwagger2StyleBodyLocationIsInvalid [ + "in: body is Swagger 2.0, not OpenAPI 3.0 (body moved to requestBody) - matches none of the oneOf location branches." + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"q","in":"body","schema":{"type":"string"}}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testPathNotStartingWithSlashIsInvalid [ + "Paths keys must match ^\/ (or the ^x- vendor-extension pattern); anything else is rejected by additionalProperties:false." + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"foo":{"get":{"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testPathParameterMissingRequiredIsInvalid [ + "PathParameter in the OAI meta-schema mandates required:true - a path parameter without it does not match any of the Parameter oneOf branches (path/query/header/cookie)." + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo/{id}":{"get":{"parameters":[{"name":"id","in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testPathParameterWithRequiredTrueIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo/{id}":{"get":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testPathWithVendorExtensionIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"responses":{"200":{"description":"ok"}}}},"x-internal-note":"draft"}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testQueryParameterOptionalIsValid [ + self assert: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"parameters":[{"name":"q","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"ok"}}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testResponsesObjectRequiresAtLeastOneEntry [ + self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/foo":{"get":{"responses":{}}}}}') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testSchemaForVersionIsCachedAcrossPatchVersions [ + OADocumentValidator resetSchemaCache. + self assert: (OADocumentValidator schemaForVersion: '3.0.0') == (OADocumentValidator schemaForVersion: '3.0.3') +] + { #category : 'tests' } OADocumentValidatorTests >> testValidMinimalDocumentIsValid [ self assert: (OADocumentValidator isValidDocument: self validMinimalDocument) @@ -43,6 +167,33 @@ OADocumentValidatorTests >> testValidateReturnsParsedDocument [ self assert: ((doc at: 'info') at: 'title') equals: 'Test API' ] +{ #category : 'tests' } +OADocumentValidatorTests >> testValidationErrorForBadPathsTypeIsJSONSchemaError [ + | error | + error := OADocumentValidator validationErrorFor: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":"nope"}'. + self assert: (error isKindOf: JSONSchemaError) +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testValidationErrorForValidDocumentIsNil [ + self assert: (OADocumentValidator validationErrorFor: self validMinimalDocument) isNil +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testValidationErrorMessageForBadPathsTypeDescribesFailure [ + | message | + message := OADocumentValidator validationErrorMessageFor: '{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":"nope"}'. + self assert: (message includesSubstring: 'JSONTypeError'). + self assert: (message includesSubstring: 'not an object') +] + +{ #category : 'tests' } +OADocumentValidatorTests >> testValidationErrorMessageForMissingInfoDescribesFailure [ + | message | + message := OADocumentValidator validationErrorMessageFor: '{"openapi":"3.0.0","paths":{}}'. + self assert: (message includesSubstring: 'JSONSchemaMissingRequiredProperty') +] + { #category : 'fixtures' } OADocumentValidatorTests >> validMinimalDocument [ ^ '{"openapi":"3.0.0","info":{"title":"Test API","version":"1.0.0"},"paths":{}}' diff --git a/source/OpenAPI-Core/OADocumentValidator.class.st b/source/OpenAPI-Core/OADocumentValidator.class.st index 7c79570..55b6bee 100644 --- a/source/OpenAPI-Core/OADocumentValidator.class.st +++ b/source/OpenAPI-Core/OADocumentValidator.class.st @@ -1,20 +1,44 @@ Class { #name : 'OADocumentValidator', #superclass : 'Object', + #classVars : [ + 'SchemaCache' + ], #category : 'OpenAPI-Core', #package : 'OpenAPI-Core' } +{ #category : 'schemas' } +OADocumentValidator class >> cacheKeyForVersion: aVersionString [ + "Mehrere Patch-Versionen (z.B. 3.0.0, 3.0.3) teilen sich dasselbe Meta-Schema. Auf den major.minor-Bucket normalisieren, damit der Cache nicht pro Patch-Version waechst." + (aVersionString beginsWith: '3.0') ifTrue: [ ^ '3.0' ]. + ^ aVersionString +] + { #category : 'validating' } OADocumentValidator class >> isValidDocument: aDocument [ "Antwortet, ob aDocument ein gültiges OpenAPI-Dokument ist (keine Exception bei der Validierung)." ^ [ self validateDocument: aDocument. true ] on: Error do: [ :e | false ] ] +{ #category : 'schemas' } +OADocumentValidator class >> resetSchemaCache [ + "Leert den Schema-Cache (z.B. für Tests oder wenn sich gebündelte Meta-Schemas ändern)." + SchemaCache := nil +] + +{ #category : 'schemas' } +OADocumentValidator class >> schemaCache [ + "Lazily initialisierter Cache: normalisierter Versions-Bucket -> gebautes JSONSchema. Vermeidet, das teure OAI-Meta-Schema bei jeder Validierung neu zu parsen/bauen." + ^ SchemaCache ifNil: [ SchemaCache := Dictionary new ] +] + { #category : 'schemas' } OADocumentValidator class >> schemaForVersion: aVersionString [ - "Baut ein JSONSchema aus dem gebündelten OAI-Meta-Schema für aVersionString." - ^ JSONSchema fromString: (OAMetaSchema schemaJsonForVersion: aVersionString) + "Baut (und cached, siehe #schemaCache) ein JSONSchema aus dem gebündelten OAI-Meta-Schema für aVersionString." + ^ self schemaCache + at: (self cacheKeyForVersion: aVersionString) + ifAbsentPut: [ JSONSchema fromString: (OAMetaSchema schemaJsonForVersion: aVersionString) ] ] { #category : 'validating' } @@ -38,3 +62,27 @@ OADocumentValidator class >> validateDocument: aDocument version: aVersionString (self schemaForVersion: aVersionString) validate: doc. ^ doc ] + +{ #category : 'validating' } +OADocumentValidator class >> validationErrorFor: aDocument [ + "Wie #isValidDocument:, liefert bei Ungültigkeit aber den tatsächlichen JSONSchemaError statt ihn zu verschlucken (welche Regel wurde verletzt). Antwortet nil, wenn das Dokument gültig ist." + ^ [ self validateDocument: aDocument. nil ] + on: JSONSchemaError + do: [ :e | e ] +] + +{ #category : 'validating' } +OADocumentValidator class >> validationErrorFor: aDocument version: aVersionString [ + "Wie #validationErrorFor:, aber mit expliziter Version statt Auto-Erkennung aus dem openapi-Feld." + ^ [ self validateDocument: aDocument version: aVersionString. nil ] + on: JSONSchemaError + do: [ :e | e ] +] + +{ #category : 'validating' } +OADocumentValidator class >> validationErrorMessageFor: aDocument [ + "Bequemlichkeit über #validationErrorFor: - liefert eine lesbare Beschreibung der verletzten Regel (Fehlerklasse + messageText), oder nil, wenn das Dokument gültig ist." + | error | + error := self validationErrorFor: aDocument. + ^ error ifNotNil: [ error class name , ': ', error messageText ] +] diff --git a/source/OpenAPI-Core/OAParameter.class.st b/source/OpenAPI-Core/OAParameter.class.st index 7721c76..9e294b4 100644 --- a/source/OpenAPI-Core/OAParameter.class.st +++ b/source/OpenAPI-Core/OAParameter.class.st @@ -300,11 +300,9 @@ OAParameter >> validateContraints: value [ { #category : 'writing' } OAParameter >> writeParameterValueOn: anObject request: request [ + "required/nicht-required ist bereits durch #extractParameter: abgedeckt: signalisiert OAMissingRequiredParameter, wenn required und fehlend; liefert nil nur, wenn optional und fehlend - dann wird hier bewusst nichts geschrieben." | value | - self flag: #todo. - "needs to be improved for required or not. If not required we don't write the - value. Needs to be checked if that constraint is ok" - value := self extractParameter: request. + value := self extractParameter: request. value ifNil: [ ^ self ]. self shouldUseAccessors ifTrue: [