From 22b8ea8a89c84557849a1c8299a850511c18e8b1 Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Sat, 1 Aug 2026 12:08:53 +0200 Subject: [PATCH 1/3] Cache built OAI meta-schema and expose real validation error diagnostics schemaForVersion: previously rebuilt the JSONSchema from the bundled OAI meta-schema JSON on every call; it now caches per major.minor version bucket. isValidDocument: only ever returned a boolean, swallowing the actual JSONSchemaError - validationErrorFor:, validationErrorFor:version: and validationErrorMessageFor: now expose which rule was violated (error class + messageText) without raising. Co-Authored-By: Claude Sonnet 5 --- .../OADocumentValidatorTests.class.st | 33 ++++++++++++ .../OpenAPI-Core/OADocumentValidator.class.st | 52 ++++++++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st b/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st index 2d92a32..b8be539 100644 --- a/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st +++ b/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st @@ -30,6 +30,12 @@ OADocumentValidatorTests >> testMissingPathsIsInvalid [ self deny: (OADocumentValidator isValidDocument: '{"openapi":"3.0.0","info":{"title":"t","version":"1"}}') ] +{ #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 +49,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 ] +] From f584a059a47748b5ca88d4b35ae3bf9ba965e04f Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Sat, 1 Aug 2026 12:08:59 +0200 Subject: [PATCH 2/3] Remove stale #todo in OAParameter>>writeParameterValueOn:request: extractParameter: already handles required-vs-optional correctly: it signals OAMissingRequiredParameter when required and absent, and only returns nil when optional and absent - which is exactly the case writeParameterValueOn:request: skips writing for. No behavior change. Co-Authored-By: Claude Sonnet 5 --- source/OpenAPI-Core/OAParameter.class.st | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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: [ From ff3573b8ffce1427f21683a857822027c7ddd458 Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Sat, 1 Aug 2026 12:26:46 +0200 Subject: [PATCH 3/3] Broaden document-validator test coverage to previously-untested meta-schema branches The prior 12 tests only exercised whole-document shape (missing info/paths, wrong paths type). The OAI 3.0 meta-schema's more interesting validation branches - Parameter's oneOf over path/query/ header/cookie locations (incl. PathParameter's forced required:true), ExampleXORExamples, SchemaXORContent, additionalProperties as schema/ boolean/invalid, the four SecurityScheme variants, Responses' minProperties, and the Paths object's ^/ pattern - had zero coverage. Note: these all include a real operation+response, which surfaces zweidenker/JSONSchema#support-non-capturing-regex-groups (the OAI Responses/component-name patterns use regex syntax Pharo's engine didn't accept) - these tests only pass once that lands. Co-Authored-By: Claude Sonnet 5 --- .../OADocumentValidatorTests.class.st | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st b/source/OpenAPI-Core-Tests/OADocumentValidatorTests.class.st index b8be539..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,82 @@ 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.