From afc09aaac0e47ca4df87a2dd0f3d946758016d5d Mon Sep 17 00:00:00 2001 From: Tim van der Lippe Date: Thu, 5 Mar 2026 11:00:00 +0100 Subject: [PATCH 1/5] Voeg regel toe voor error structuur bij Bad Requests Hier was initieel geen consensus voor bij de behandeling van het hoofdstuk "Error Handling". --- .../expected-output.txt | 7 + .../error-type-bad-invalid-input/openapi.json | 232 ++++++++++++++ .../expected-output.txt | 6 + .../error-type-bad-request/openapi.json | 295 ++++++++++++++++++ .../paths-kebab-variables/expected-output.txt | 8 +- .../expected-output.txt | 5 +- .../query-keys-camel-case/expected-output.txt | 13 +- media/linter.yaml | 54 ++++ sections/designRules.md | 89 ++++++ 9 files changed, 700 insertions(+), 9 deletions(-) create mode 100644 linter/testcases/error-type-bad-invalid-input/expected-output.txt create mode 100644 linter/testcases/error-type-bad-invalid-input/openapi.json create mode 100644 linter/testcases/error-type-bad-request/expected-output.txt create mode 100644 linter/testcases/error-type-bad-request/openapi.json diff --git a/linter/testcases/error-type-bad-invalid-input/expected-output.txt b/linter/testcases/error-type-bad-invalid-input/expected-output.txt new file mode 100644 index 00000000..69b4bb4e --- /dev/null +++ b/linter/testcases/error-type-bad-invalid-input/expected-output.txt @@ -0,0 +1,7 @@ + +/testcases/error-type-bad-invalid-input/openapi.json + 119:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.get.responses + 157:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.put.responses + 195:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.post.responses + +✖ 3 problems (3 errors, 0 warnings, 0 infos, 0 hints) diff --git a/linter/testcases/error-type-bad-invalid-input/openapi.json b/linter/testcases/error-type-bad-invalid-input/openapi.json new file mode 100644 index 00000000..c8ab9261 --- /dev/null +++ b/linter/testcases/error-type-bad-invalid-input/openapi.json @@ -0,0 +1,232 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Baseline", + "description": "Deze OpenAPI specification bevat het minimale om aan alle regels te voldoen.", + "contact": { + "name": "Beheerder", + "url": "https://www.example.com", + "email": "mail@example.com" + }, + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://example.com/api/v1" + } + ], + "security": [ + { + "default": [] + } + ], + "tags": [ + { + "name": "openapi" + }, + { + "name": "resource" + } + ], + "paths": { + "/openapi.json": { + "get": { + "tags": [ + "openapi" + ], + "description": "OpenAPI document", + "operationId": "getOpenapiJSON", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + }, + "access-control-allow-origin": { + "description": "Alle origins mogen bij deze resource", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { + "type": "object", + "properties": { + "pointer": { "type": "string" }, + "name": { "type": "string" }, + "index": { "type": "integer" } + } + }, + "code": { "type": "string" }, + "detail": { "type": "string" } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/invalid-response-vereist": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesCorrect", + "parameters": [ + { + "name": "expand", + "in": "query", + "description": "Schakelaar om details van gekoppelde organisaties (subOIN of OINhouder) op te vragen (default false = geen details)", + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + }, + "put": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "putPropertiesCorrect", + "parameters": [ + { + "name": "expand", + "in": "query", + "description": "Schakelaar om details van gekoppelde organisaties (subOIN of OINhouder) op te vragen (default false = geen details)", + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + }, + "post": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "postPropertiesCorrect", + "parameters": [ + { + "name": "expand", + "in": "query", + "description": "Schakelaar om details van gekoppelde organisaties (subOIN of OINhouder) op te vragen (default false = geen details)", + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + } + }, + "components": { + "schemas": { + }, + "securitySchemes": { + "default": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://test.com", + "scopes": {} + } + } + } + } + } +} \ No newline at end of file diff --git a/linter/testcases/error-type-bad-request/expected-output.txt b/linter/testcases/error-type-bad-request/expected-output.txt new file mode 100644 index 00000000..7dbc0bd2 --- /dev/null +++ b/linter/testcases/error-type-bad-request/expected-output.txt @@ -0,0 +1,6 @@ + +/testcases/error-type-bad-request/openapi.json + 133:58 error nlgov:problem-schema-members-bad-request Property "extra" is not expected to be here paths./properties-correct.get.responses[400].content.application/problem+json.schema.properties.errors.properties + 254:66 error nlgov:problem-schema-members-bad-request Property "pointer2" is not expected to be here paths./properties-location-verkeerd-format.get.responses[400].content.application/problem+json.schema.properties.errors.properties.location.properties + +✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints) diff --git a/linter/testcases/error-type-bad-request/openapi.json b/linter/testcases/error-type-bad-request/openapi.json new file mode 100644 index 00000000..1ee4a307 --- /dev/null +++ b/linter/testcases/error-type-bad-request/openapi.json @@ -0,0 +1,295 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Baseline", + "description": "Deze OpenAPI specification bevat het minimale om aan alle regels te voldoen.", + "contact": { + "name": "Beheerder", + "url": "https://www.example.com", + "email": "mail@example.com" + }, + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://example.com/api/v1" + } + ], + "security": [ + { + "default": [] + } + ], + "tags": [ + { + "name": "openapi" + }, + { + "name": "resource" + } + ], + "paths": { + "/openapi.json": { + "get": { + "tags": [ + "openapi" + ], + "description": "OpenAPI document", + "operationId": "getOpenapiJSON", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + }, + "access-control-allow-origin": { + "description": "Alle origins mogen bij deze resource", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { + "type": "object", + "properties": { + "pointer": { "type": "string" }, + "name": { "type": "string" }, + "index": { "type": "integer" } + } + }, + "code": { "type": "string" }, + "detail": { "type": "string" } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-correct": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesCorrect", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { + "type": "object", + "properties": { + "pointer": { "type": "string" }, + "name": { "type": "string" }, + "index": { "type": "integer" } + } + }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "extra": { "type": "string" } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-zonder-location": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getResource", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-location-verkeerd-format": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesVerkeerdFormat", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { + "type": "object", + "properties": { + "pointer2": { "type": "string" }, + "name": { "type": "string" }, + "index": { "type": "integer" } + } + }, + "code": { "type": "string" }, + "detail": { "type": "string" } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + } + }, + "components": { + "schemas": { + }, + "securitySchemes": { + "default": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://test.com", + "scopes": {} + } + } + } + } + } +} \ No newline at end of file diff --git a/linter/testcases/paths-kebab-variables/expected-output.txt b/linter/testcases/paths-kebab-variables/expected-output.txt index 95cc954a..97a1f2e8 100644 --- a/linter/testcases/paths-kebab-variables/expected-output.txt +++ b/linter/testcases/paths-kebab-variables/expected-output.txt @@ -1 +1,7 @@ -No results with a severity of 'error' found! + +/testcases/paths-kebab-variables/openapi.json + 87:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./organisaties/{id}.get.responses + 128:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./organisaties/{id}/nested.get.responses + 169:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./organisaties/{organisationId}/pad.get.responses + +✖ 3 problems (3 errors, 0 warnings, 0 infos, 0 hints) diff --git a/linter/testcases/paths-kebab-zoek-uitzondering/expected-output.txt b/linter/testcases/paths-kebab-zoek-uitzondering/expected-output.txt index d1c1822b..b4423b4b 100644 --- a/linter/testcases/paths-kebab-zoek-uitzondering/expected-output.txt +++ b/linter/testcases/paths-kebab-zoek-uitzondering/expected-output.txt @@ -1,5 +1,6 @@ /testcases/paths-kebab-zoek-uitzondering/openapi.json - 125:19 warning path-keys-no-trailing-slash Path must not end with slash. paths./_zoek/ + 125:19 warning path-keys-no-trailing-slash Path must not end with slash. paths./_zoek/ + 174:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./organisaties/{id}/nested/_zoek.get.responses -✖ 1 problem (0 errors, 1 warning, 0 infos, 0 hints) +✖ 2 problems (1 error, 1 warning, 0 infos, 0 hints) diff --git a/linter/testcases/query-keys-camel-case/expected-output.txt b/linter/testcases/query-keys-camel-case/expected-output.txt index 3e99836d..2afb315a 100644 --- a/linter/testcases/query-keys-camel-case/expected-output.txt +++ b/linter/testcases/query-keys-camel-case/expected-output.txt @@ -1,9 +1,10 @@ /testcases/query-keys-camel-case/openapi.json - 84:33 error nlgov:query-keys-camel-case kebab-case is not lower camelCase. paths./resource.get.parameters[1].name - 91:33 error nlgov:query-keys-camel-case _startMetUnderscore is not lower camelCase. paths./resource.get.parameters[2].name - 98:33 error nlgov:query-keys-camel-case 9startMetGetal is not lower camelCase. paths./resource.get.parameters[3].name - 105:33 error nlgov:query-keys-camel-case snake_case is not lower camelCase. paths./resource.get.parameters[4].name - 112:33 error nlgov:query-keys-camel-case UpperCamelCase is not lower camelCase. paths./resource.get.parameters[5].name + 84:33 error nlgov:query-keys-camel-case kebab-case is not lower camelCase. paths./resource.get.parameters[1].name + 91:33 error nlgov:query-keys-camel-case _startMetUnderscore is not lower camelCase. paths./resource.get.parameters[2].name + 98:33 error nlgov:query-keys-camel-case 9startMetGetal is not lower camelCase. paths./resource.get.parameters[3].name + 105:33 error nlgov:query-keys-camel-case snake_case is not lower camelCase. paths./resource.get.parameters[4].name + 112:33 error nlgov:query-keys-camel-case UpperCamelCase is not lower camelCase. paths./resource.get.parameters[5].name + 118:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./resource.get.responses -✖ 5 problems (5 errors, 0 warnings, 0 infos, 0 hints) +✖ 6 problems (6 errors, 0 warnings, 0 infos, 0 hints) diff --git a/media/linter.yaml b/media/linter.yaml index a64854fc..2d5a227a 100644 --- a/media/linter.yaml +++ b/media/linter.yaml @@ -213,6 +213,60 @@ rules: - title - detail + #/core/error-handling/invalid-input + nlgov:problem-invalid-input: + severity: error + message: "GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response" + given: + - $.paths..[?( @property.match(/(get)|(delete)/) && @.parameters && @.parameters.length > 0 )] + - $.paths..[?( @property.match(/(put)|(post)|(patch)/))] + then: + function: schema + functionOptions: + schema: + type: object + properties: + responses: + type: object + required: + - "400" + + #/core/error-handling/bad-request + nlgov:problem-schema-members-bad-request: + severity: error + given: $..[responses][?(@property && @property.match(400))].content[?(@property=="application/problem+json" || @property=="application/problem+xml")]..schema.properties + then: + function: schema + functionOptions: + schema: + type: object + properties: + errors: + type: object + properties: + properties: + type: object + properties: + in: {} + detail: {} + location: + type: object + properties: + properties: + type: object + properties: + pointer: {} + name: {} + index: {} + additionalProperties: false + code: {} + required: + - in + - detail + additionalProperties: false + required: + - errors + nlgov:property-casing: severity: warn given: diff --git a/sections/designRules.md b/sections/designRules.md index 139554b9..09d73dca 100644 --- a/sections/designRules.md +++ b/sections/designRules.md @@ -637,6 +637,95 @@ Content-Type: application/problem+json{ +
+

Add specific errors for Bad Request responses

+
+
Statement
+
+

Problem details with status code 400 (Bad Request) MUST include an additional member errors containing an ordered list of validation error objects, as specified below. +

Each error object MUST contain in and detail members, and MAY optionally contain location, index and code members. +

    +
  • in - where the error occurs: body or query.
  • +
  • detail - a human-readable message describing the violation.
  • +
  • location (optional) - a locator for the offending value: +
      +
    • For JSON request bodies: a JSON Pointer [[rfc6901]] expression pointing to the value.
    • +
    • For XML request bodies: an absolute XPath v3.1 [[xpath-31]] expression pointing to the value.
    • +
    • For query parameters: the parameter name.
    • +
    + For body errors, the location member may be omitted, in case the error refers to the body as a whole (e.g. syntax errors). +
  • +
  • index (optional) - a zero-based index position when multiple query parameters have the same name.
  • +
  • code (optional) - a short, stable machine-readable code as a rule identifier (e.g. date.format). If a type URI is provided on the message-level, dereferencing this URI SHOULD result in a page describing all possible code values including a description for each value.
  • +
+ + +
+
Rationale
+
+

Having a single, consistent errors structure makes validation issues predictable for clients, while relying on established locators using universal standards (JSON Pointer, XPath).

+
+
How to test
+
+ Verify all responses with status code 400 contain a required errors member conforming to the requirements above. +
+
+
+

Return all errors together for bad requests

From 56ee465b5027926f97263e756aba4ecc3c88ff1b Mon Sep 17 00:00:00 2001 From: Tim van der Lippe Date: Tue, 14 Apr 2026 17:01:01 +0200 Subject: [PATCH 2/5] Beheer: update Quarkus example met invalide query parameters --- examples/quarkus/pom.xml | 17 ++++- .../main/java/org/acme/BadRequestProblem.java | 65 +++++++++++++++++++ .../main/java/org/acme/GreetingResource.java | 64 +++++++++++++++--- media/linter.yaml | 33 ++++++---- 4 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 examples/quarkus/src/main/java/org/acme/BadRequestProblem.java diff --git a/examples/quarkus/pom.xml b/examples/quarkus/pom.xml index a60366fd..1d9b178f 100644 --- a/examples/quarkus/pom.xml +++ b/examples/quarkus/pom.xml @@ -12,7 +12,7 @@ UTF-8 quarkus-bom io.quarkus.platform - 3.21.1 + 3.34.3 true 3.5.2 @@ -44,7 +44,20 @@ io.quarkus - quarkus-junit5 + quarkus-rest-jackson + + + io.quarkus + quarkus-hibernate-validator + + + io.quarkiverse.resteasy-problem + quarkus-resteasy-problem + 3.32.0 + + + io.quarkus + quarkus-junit test diff --git a/examples/quarkus/src/main/java/org/acme/BadRequestProblem.java b/examples/quarkus/src/main/java/org/acme/BadRequestProblem.java new file mode 100644 index 00000000..02983b07 --- /dev/null +++ b/examples/quarkus/src/main/java/org/acme/BadRequestProblem.java @@ -0,0 +1,65 @@ +package org.acme; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkiverse.resteasy.problem.HttpProblem; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.util.List; + +public class BadRequestProblem extends HttpProblem { + protected BadRequestProblem(String message, List errors) { + super( + builder() + .withTitle("Bad hello request") + .withStatus(Response.Status.BAD_REQUEST) + .withDetail(message) + .with("errors", errors)); + } + + @Schema(name = "errors", description = "All bad request errors") + public List errors; + + @Schema( + name = "BadRequestDetails", + description = "Request details according to API Design Rules") + public static class BadRequestDetails { + @Schema(description = "Where the error occurs") + public BadRequestLocation in; + + @Schema(description = "Human-readable message describing the violation") + public String detail; + + @Schema(description = "A locator for the offending value") + public String location; + + @Schema( + nullable = true, + description = + "A zero-based index position when multiple query parameters have the same name") + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public Integer index; + + private BadRequestDetails( + BadRequestLocation in, String detail, String location, Integer index) { + this.in = in; + this.detail = detail; + this.location = location; + this.index = index; + } + + public static BadRequestDetails forQuery( + BadRequestLocation in, String detail, Integer index) { + return new BadRequestDetails(in, detail, "query", index); + } + } + + public enum BadRequestLocation { + @JsonProperty("body") + Body, + @JsonProperty("query") + Query, + ; + } +} diff --git a/examples/quarkus/src/main/java/org/acme/GreetingResource.java b/examples/quarkus/src/main/java/org/acme/GreetingResource.java index ad0db735..cd5a5208 100644 --- a/examples/quarkus/src/main/java/org/acme/GreetingResource.java +++ b/examples/quarkus/src/main/java/org/acme/GreetingResource.java @@ -1,8 +1,8 @@ package org.acme; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.headers.Header; @@ -10,26 +10,70 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.resteasy.reactive.RestQuery; +import org.jboss.resteasy.reactive.Separator; + +import java.util.List; +import java.util.Map; @Path("/hello") @Tag(name = "greeting") public class GreetingResource { @GET - @Operation( - description = "Hello world endpoint" - ) + @Operation(description = "Hello world endpoint") @APIResponse( responseCode = "200", content = @Content(mediaType = MediaType.TEXT_PLAIN), headers = { - @Header( - name = OpenApiConstants.API_VERSION_HEADER_NAME, - schema = @Schema(implementation = String.class) - ) - } - ) + @Header( + name = OpenApiConstants.API_VERSION_HEADER_NAME, + schema = @Schema(implementation = String.class)) + }) public String hello() { return "Hello from Quarkus REST"; } + + @POST + @Operation(description = "Test bad request with query parameter") + @APIResponse( + responseCode = "200", + content = @Content(mediaType = MediaType.TEXT_PLAIN), + headers = { + @Header( + name = OpenApiConstants.API_VERSION_HEADER_NAME, + schema = @Schema(implementation = String.class)) + }) + @APIResponse( + responseCode = "400", + description = "Bad request response", + content = + @Content( + mediaType = "application/problem+json", + schema = @Schema(implementation = BadRequestProblem.class))) + public String withInput(@RestQuery("query") @Separator(",") List queryParam) { + if (queryParam.isEmpty()) { + throw new BadRequestProblem( + "Missing required query parameter", + List.of( + BadRequestProblem.BadRequestDetails.forQuery( + BadRequestProblem.BadRequestLocation.Query, + "Query parameter is invalid", + null))); + } + var queryParams = queryParam.iterator(); + for (var index = 0; queryParams.hasNext(); index++) { + var param = queryParams.next(); + if (!param.equals("42")) { + throw new BadRequestProblem( + "Missing required query parameter", + List.of( + BadRequestProblem.BadRequestDetails.forQuery( + BadRequestProblem.BadRequestLocation.Query, + "Query parameter is invalid", + index))); + } + } + return "Successful response"; + } } diff --git a/media/linter.yaml b/media/linter.yaml index 2d5a227a..78a67ba8 100644 --- a/media/linter.yaml +++ b/media/linter.yaml @@ -244,26 +244,33 @@ rules: errors: type: object properties: - properties: + items: type: object properties: - in: {} - detail: {} - location: + properties: type: object properties: - properties: + in: {} + detail: {} + location: type: object properties: - pointer: {} - name: {} - index: {} - additionalProperties: false - code: {} + properties: + type: object + properties: + pointer: {} + name: {} + index: {} + additionalProperties: false + code: {} + required: + - in + - detail + - location required: - - in - - detail - additionalProperties: false + - properties + required: + - items required: - errors From 0e521e525016ad4b28407a8c314331ab19875238 Mon Sep 17 00:00:00 2001 From: Tim van der Lippe Date: Thu, 16 Apr 2026 14:25:33 +0200 Subject: [PATCH 3/5] Maak Quarkus voorbeeld compleet --- .../main/java/org/acme/BadRequestProblem.java | 29 +- .../main/java/org/acme/GreetingResource.java | 47 ++- .../main/java/org/acme/PostRequestyBody.java | 49 +++ .../java/org/acme/GreetingResourceTest.java | 114 +++++- .../expected-output.txt | 6 +- .../error-type-bad-invalid-input/openapi.json | 23 +- .../expected-output.txt | 11 +- .../error-type-bad-request/openapi.json | 383 +++++++++++++++--- media/linter.yaml | 36 +- 9 files changed, 592 insertions(+), 106 deletions(-) create mode 100644 examples/quarkus/src/main/java/org/acme/PostRequestyBody.java diff --git a/examples/quarkus/src/main/java/org/acme/BadRequestProblem.java b/examples/quarkus/src/main/java/org/acme/BadRequestProblem.java index 02983b07..82d4842c 100644 --- a/examples/quarkus/src/main/java/org/acme/BadRequestProblem.java +++ b/examples/quarkus/src/main/java/org/acme/BadRequestProblem.java @@ -31,7 +31,8 @@ public static class BadRequestDetails { @Schema(description = "Human-readable message describing the violation") public String detail; - @Schema(description = "A locator for the offending value") + @Schema(nullable = true, description = "A locator for the offending value") + @JsonInclude(value = JsonInclude.Include.NON_NULL) public String location; @Schema( @@ -41,17 +42,35 @@ public static class BadRequestDetails { @JsonInclude(value = JsonInclude.Include.NON_NULL) public Integer index; + @Schema( + nullable = true, + description = "A short, stable machine-readable code as a rule identifier") + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public String code; + private BadRequestDetails( - BadRequestLocation in, String detail, String location, Integer index) { + BadRequestLocation in, String detail, String location, Integer index, String code) { this.in = in; this.detail = detail; this.location = location; this.index = index; + this.code = code; + } + + public static BadRequestDetails forSingleQuery( + String queryParameterName, String detail, String code) { + return new BadRequestDetails( + BadRequestLocation.Query, detail, queryParameterName, null, code); + } + + public static BadRequestDetails forIndexedQuery( + String queryParameterName, String detail, Integer index, String code) { + return new BadRequestDetails( + BadRequestLocation.Query, detail, queryParameterName, index, code); } - public static BadRequestDetails forQuery( - BadRequestLocation in, String detail, Integer index) { - return new BadRequestDetails(in, detail, "query", index); + public static BadRequestDetails forBody(String location, String detail, String code) { + return new BadRequestDetails(BadRequestLocation.Body, detail, location, null, code); } } diff --git a/examples/quarkus/src/main/java/org/acme/GreetingResource.java b/examples/quarkus/src/main/java/org/acme/GreetingResource.java index cd5a5208..ecc6a7f9 100644 --- a/examples/quarkus/src/main/java/org/acme/GreetingResource.java +++ b/examples/quarkus/src/main/java/org/acme/GreetingResource.java @@ -13,8 +13,8 @@ import org.jboss.resteasy.reactive.RestQuery; import org.jboss.resteasy.reactive.Separator; +import java.util.ArrayList; import java.util.List; -import java.util.Map; @Path("/hello") @Tag(name = "greeting") @@ -51,29 +51,40 @@ public String hello() { @Content( mediaType = "application/problem+json", schema = @Schema(implementation = BadRequestProblem.class))) - public String withInput(@RestQuery("query") @Separator(",") List queryParam) { - if (queryParam.isEmpty()) { - throw new BadRequestProblem( - "Missing required query parameter", - List.of( - BadRequestProblem.BadRequestDetails.forQuery( - BadRequestProblem.BadRequestLocation.Query, - "Query parameter is invalid", - null))); + public String withInput( + @RestQuery("queryInput") @Separator(",") List queryInputParam, + PostRequestyBody body) { + var errors = new ArrayList(); + if (queryInputParam.isEmpty()) { + errors.add( + BadRequestProblem.BadRequestDetails.forSingleQuery( + "queryInput", + "Missing required query parameter", + "input.query.missing")); } - var queryParams = queryParam.iterator(); + var queryParams = queryInputParam.iterator(); for (var index = 0; queryParams.hasNext(); index++) { var param = queryParams.next(); if (!param.equals("42")) { - throw new BadRequestProblem( - "Missing required query parameter", - List.of( - BadRequestProblem.BadRequestDetails.forQuery( - BadRequestProblem.BadRequestLocation.Query, - "Query parameter is invalid", - index))); + errors.add( + BadRequestProblem.BadRequestDetails.forIndexedQuery( + "queryInput", + "Value for query parameter is not 42, but was %s".formatted(param), + index, + "input.query.invalid")); } } + if (!body.field.equals("foo")) { + errors.add( + BadRequestProblem.BadRequestDetails.forBody( + "field", + "Value for field in body should be foo, but was %s" + .formatted(body.field), + "input.body.field.invalid")); + } + if (!errors.isEmpty()) { + throw new BadRequestProblem("Failed to process request input", errors); + } return "Successful response"; } } diff --git a/examples/quarkus/src/main/java/org/acme/PostRequestyBody.java b/examples/quarkus/src/main/java/org/acme/PostRequestyBody.java new file mode 100644 index 00000000..af1b31e4 --- /dev/null +++ b/examples/quarkus/src/main/java/org/acme/PostRequestyBody.java @@ -0,0 +1,49 @@ +package org.acme; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.ext.MessageBodyReader; +import jakarta.ws.rs.ext.Provider; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.List; + +public class PostRequestyBody { + @JsonProperty("field") + String field; + + @Provider + public static class PostRequestyBodyHandler implements MessageBodyReader { + @Override + public boolean isReadable( + Class aClass, Type type, Annotation[] annotations, MediaType mediaType) { + return type == PostRequestyBody.class; + } + + @Override + public PostRequestyBody readFrom( + Class aClass, + Type type, + Annotation[] annotations, + MediaType mediaType, + MultivaluedMap httpHeaders, + InputStream entityStream) + throws BadRequestProblem { + try { + return new ObjectMapper().readValue(entityStream, PostRequestyBody.class); + } catch (IOException e) { + throw new BadRequestProblem( + "Failed to parse body", + List.of( + BadRequestProblem.BadRequestDetails.forBody( + null, "Failed to parse body", "body.parsing.failed"))); + } + } + } +} diff --git a/examples/quarkus/src/test/java/org/acme/GreetingResourceTest.java b/examples/quarkus/src/test/java/org/acme/GreetingResourceTest.java index 18332e4c..66494739 100644 --- a/examples/quarkus/src/test/java/org/acme/GreetingResourceTest.java +++ b/examples/quarkus/src/test/java/org/acme/GreetingResourceTest.java @@ -4,17 +4,117 @@ import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.*; @QuarkusTest class GreetingResourceTest { + + private static final String VALID_BODY = "{\"field\": \"foo\"}"; + @Test void testHelloEndpoint() { - given() - .when().get("/hello") - .then() - .statusCode(200) - .body(is("Hello from Quarkus REST")); + given().when().get("/hello").then().statusCode(200).body(is("Hello from Quarkus REST")); } -} \ No newline at end of file + @Test + void testMissingQueryParameter() { + given().body(VALID_BODY) + .when() + .post("/hello") + .then() + .statusCode(400) + .body( + "detail", + equalTo("Failed to process request input"), + "instance", + equalTo("/hello"), + "errors", + hasSize(1), + "errors.in", + hasItems("query"), + "errors.code", + hasItems("input.query.missing"), + "errors.location", + hasItems("queryInput"), + "errors.detail", + hasItems("Missing required query parameter"), + "errors[0]", + not(hasKey("index"))); + } + + @Test + void testInvalidQueryParameterValue() { + given().body(VALID_BODY) + .when() + .post("/hello?queryInput=43,42,41") + .then() + .statusCode(400) + .body( + "detail", + equalTo("Failed to process request input"), + "instance", + equalTo("/hello"), + "errors", + hasSize(2), + "errors.in", + hasItems("query", "query"), + "errors.code", + hasItems("input.query.invalid", "input.query.invalid"), + "errors.location", + hasItems("queryInput", "queryInput"), + "errors.index", + hasItems(0, 2), + "errors.detail", + hasItems( + "Value for query parameter is not 42, but was 43", + "Value for query parameter is not 42, but was 41")); + } + + @Test + void testInvalidRequestBody() { + given().body("{\"fiel\": \"foo\"}") + .when() + .post("/hello?queryInput=42") + .then() + .statusCode(400) + .body( + "detail", + equalTo("Failed to parse body"), + "instance", + equalTo("/hello"), + "errors", + hasSize(1), + "errors.in", + hasItems("body"), + "errors.code", + hasItems("body.parsing.failed"), + "errors[0]", + allOf(not(hasKey("location")), not(hasKey("index"))), + "errors.detail", + hasItems("Failed to parse body")); + } + + @Test + void testReturnsBothBodySchemaIssuesAndQueryParamFailures() { + given().body("{\"field\": \"bar\"}") + .when() + .post("/hello?queryInput=41") + .then() + .statusCode(400) + .body( + "detail", + equalTo("Failed to process request input"), + "instance", + equalTo("/hello"), + "errors", + hasSize(2), + "errors.in", + hasItems("query", "body"), + "errors.code", + hasItems("input.query.invalid", "input.body.field.invalid"), + "errors.detail", + hasItems( + "Value for query parameter is not 42, but was 41", + "Value for field in body should be foo, but was bar")); + } +} diff --git a/linter/testcases/error-type-bad-invalid-input/expected-output.txt b/linter/testcases/error-type-bad-invalid-input/expected-output.txt index 69b4bb4e..d0ed9f0e 100644 --- a/linter/testcases/error-type-bad-invalid-input/expected-output.txt +++ b/linter/testcases/error-type-bad-invalid-input/expected-output.txt @@ -1,7 +1,7 @@ /testcases/error-type-bad-invalid-input/openapi.json - 119:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.get.responses - 157:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.put.responses - 195:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.post.responses + 116:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.get.responses + 154:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.put.responses + 192:29 error nlgov:problem-invalid-input GET and DELETE endpoints that have parameters, and all other endpoints must be able to return a 400 response paths./invalid-response-vereist.post.responses ✖ 3 problems (3 errors, 0 warnings, 0 infos, 0 hints) diff --git a/linter/testcases/error-type-bad-invalid-input/openapi.json b/linter/testcases/error-type-bad-invalid-input/openapi.json index c8ab9261..9655af0b 100644 --- a/linter/testcases/error-type-bad-invalid-input/openapi.json +++ b/linter/testcases/error-type-bad-invalid-input/openapi.json @@ -67,19 +67,16 @@ "title": { "type": "string" }, "detail": { "type": "string" }, "errors": { - "type": "object", - "properties": { - "in": { "type": "string" }, - "location": { - "type": "object", - "properties": { - "pointer": { "type": "string" }, - "name": { "type": "string" }, - "index": { "type": "integer" } - } - }, - "code": { "type": "string" }, - "detail": { "type": "string" } + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } } } }, diff --git a/linter/testcases/error-type-bad-request/expected-output.txt b/linter/testcases/error-type-bad-request/expected-output.txt index 7dbc0bd2..9954dc76 100644 --- a/linter/testcases/error-type-bad-request/expected-output.txt +++ b/linter/testcases/error-type-bad-request/expected-output.txt @@ -1,6 +1,11 @@ /testcases/error-type-bad-request/openapi.json - 133:58 error nlgov:problem-schema-members-bad-request Property "extra" is not expected to be here paths./properties-correct.get.responses[400].content.application/problem+json.schema.properties.errors.properties - 254:66 error nlgov:problem-schema-members-bad-request Property "pointer2" is not expected to be here paths./properties-location-verkeerd-format.get.responses[400].content.application/problem+json.schema.properties.errors.properties.location.properties + 191:62 error nlgov:problem-schema-members-bad-request "properties" property must have required property "location" paths./properties-zonder-location.get.responses[400].content.application/problem+json.schema.properties.errors.items.properties + 250:69 error nlgov:problem-schema-members-bad-request "type" property must be equal to constant paths./properties-verkeerd-formaat-in.get.responses[400].content.application/problem+json.schema.properties.errors.items.properties.in.type + 310:75 error nlgov:problem-schema-members-bad-request "type" property must be equal to constant paths./properties-verkeerd-formaat-location.get.responses[400].content.application/problem+json.schema.properties.errors.items.properties.location.type + 370:71 error nlgov:problem-schema-members-bad-request "type" property must be equal to constant paths./properties-verkeerd-formaat-code.get.responses[400].content.application/problem+json.schema.properties.errors.items.properties.code.type + 430:73 error nlgov:problem-schema-members-bad-request "type" property must be equal to constant paths./properties-verkeerd-formaat-detail.get.responses[400].content.application/problem+json.schema.properties.errors.items.properties.detail.type + 490:72 error nlgov:problem-schema-members-bad-request "type" property must be equal to constant paths./properties-verkeerd-formaat-index.get.responses[400].content.application/problem+json.schema.properties.errors.items.properties.index.type + 490:72 error nlgov:problem-schema-members-bad-request "type" property must match exactly one schema in oneOf paths./properties-verkeerd-formaat-index.get.responses[400].content.application/problem+json.schema.properties.errors.items.properties.index.type -✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints) +✖ 7 problems (7 errors, 0 warnings, 0 infos, 0 hints) diff --git a/linter/testcases/error-type-bad-request/openapi.json b/linter/testcases/error-type-bad-request/openapi.json index 1ee4a307..42d20e30 100644 --- a/linter/testcases/error-type-bad-request/openapi.json +++ b/linter/testcases/error-type-bad-request/openapi.json @@ -67,19 +67,16 @@ "title": { "type": "string" }, "detail": { "type": "string" }, "errors": { - "type": "object", - "properties": { - "in": { "type": "string" }, - "location": { - "type": "object", - "properties": { - "pointer": { "type": "string" }, - "name": { "type": "string" }, - "index": { "type": "integer" } - } - }, - "code": { "type": "string" }, - "detail": { "type": "string" } + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } } } }, @@ -129,20 +126,16 @@ "title": { "type": "string" }, "detail": { "type": "string" }, "errors": { - "type": "object", - "properties": { - "in": { "type": "string" }, - "location": { - "type": "object", - "properties": { - "pointer": { "type": "string" }, - "name": { "type": "string" }, - "index": { "type": "integer" } - } - }, - "code": { "type": "string" }, - "detail": { "type": "string" }, - "extra": { "type": "string" } + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } } } }, @@ -192,11 +185,15 @@ "title": { "type": "string" }, "detail": { "type": "string" }, "errors": { - "type": "object", - "properties": { - "in": { "type": "string" }, - "code": { "type": "string" }, - "detail": { "type": "string" } + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } } } }, @@ -214,13 +211,13 @@ ] } }, - "/properties-location-verkeerd-format": { + "/properties-verkeerd-formaat-in": { "get": { "tags": [ "resource" ], "description": "Resource", - "operationId": "getPropertiesVerkeerdFormat", + "operationId": "getPropertiesVerkeerdFormatIn", "parameters": [], "responses": { "200": { @@ -246,19 +243,311 @@ "title": { "type": "string" }, "detail": { "type": "string" }, "errors": { - "type": "object", - "properties": { - "in": { "type": "string" }, - "location": { - "type": "object", - "properties": { - "pointer2": { "type": "string" }, - "name": { "type": "string" }, - "index": { "type": "integer" } - } - }, - "code": { "type": "string" }, - "detail": { "type": "string" } + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "number" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-verkeerd-formaat-location": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesVerkeerdFormatLocation", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "number" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-verkeerd-formaat-code": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesVerkeerdFormatCode", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "number" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-verkeerd-formaat-detail": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesVerkeerdFormatDetail", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "number" }, + "index": { "type": "number" } + } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-verkeerd-formaat-index": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesVerkeerdFormatIndex", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "string" } + } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-index-als-integer": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesIndexAlsInteger", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "headers": { + "API-Version": { + "description": "De huidige versie van de applicatie", + "style": "simple", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "OK", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "integer" }, + "title": { "type": "string" }, + "detail": { "type": "string" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "integer" } + } } } }, diff --git a/media/linter.yaml b/media/linter.yaml index 78a67ba8..e4e1d90c 100644 --- a/media/linter.yaml +++ b/media/linter.yaml @@ -250,23 +250,39 @@ rules: properties: type: object properties: - in: {} - detail: {} + in: + type: object + properties: + type: + const: string + detail: + type: object + properties: + type: + const: string location: type: object properties: - properties: - type: object - properties: - pointer: {} - name: {} - index: {} - additionalProperties: false - code: {} + type: + const: string + code: + type: object + properties: + type: + const: string + index: + type: object + properties: + type: + oneOf: + - const: number + - const: integer required: - in - detail - location + - code + - index required: - properties required: From 9e18986f8c058a973f28bd755cd938fe6ed01277 Mon Sep 17 00:00:00 2001 From: Tim van der Lippe Date: Thu, 16 Apr 2026 14:29:53 +0200 Subject: [PATCH 4/5] Gebruik hoofdletter RFC notatie --- sections/designRules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sections/designRules.md b/sections/designRules.md index 09d73dca..0737972b 100644 --- a/sections/designRules.md +++ b/sections/designRules.md @@ -649,7 +649,7 @@ Content-Type: application/problem+json{
  • detail - a human-readable message describing the violation.
  • location (optional) - a locator for the offending value:
      -
    • For JSON request bodies: a JSON Pointer [[rfc6901]] expression pointing to the value.
    • +
    • For JSON request bodies: a JSON Pointer [[RFC6901]] expression pointing to the value.
    • For XML request bodies: an absolute XPath v3.1 [[xpath-31]] expression pointing to the value.
    • For query parameters: the parameter name.
    From f727644df3ed0d80db4f44fe2729c978a644a0df Mon Sep 17 00:00:00 2001 From: Tim van der Lippe Date: Thu, 16 Apr 2026 16:46:24 +0200 Subject: [PATCH 5/5] Start met Node ExpressJS implementatie --- examples/express/package.json | 1 + examples/express/src/server.js | 152 ++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/examples/express/package.json b/examples/express/package.json index 4e7511e7..f682c28e 100644 --- a/examples/express/package.json +++ b/examples/express/package.json @@ -1,5 +1,6 @@ { "name": "express-example", + "type": "module", "dependencies": { "@wesleytodd/openapi": "^1.1.0", "express": "^5.2.0" diff --git a/examples/express/src/server.js b/examples/express/src/server.js index 95d4f220..06ab95a1 100644 --- a/examples/express/src/server.js +++ b/examples/express/src/server.js @@ -1,5 +1,8 @@ -const express = require('express'); -const openapi = require('@wesleytodd/openapi'); +// @ts-check + +import express from 'express'; +import openapi from '@wesleytodd/openapi'; + const app = express(); const port = 8080; @@ -12,6 +15,79 @@ const API_VERSION_HEADER_SCHEMA = { type: 'string' } }; +const BAD_REQUEST_RESPONSE = { + 'application/problem+json': { + schema: { + type: 'object', + properties: { + status: { + type: 'integer' + }, + title: { + type: 'string' + }, + detail: { + type: 'string' + }, + errors: { + type: 'array', + items: { + type: 'object', + properties: { + in: { + type: 'string' + }, + location: { + type: 'string' + }, + code: { + type: 'string' + }, + detail: { + type: 'string' + }, + index: { + type: 'integer' + } + } + } + } + } + } + } +} + +/** + * @typedef {{ in: "body"|"query", detail: string, location?: string, index?: number, code?: string }} BadRequestError + */ + +class BadRequestResponseWithErrors extends Error { + /** + * @param {string} title + * @param {string} detail + * @param {Array} errors + */ + constructor(title, detail, errors) { + super(); + + this.title = title; + this.detail = detail; + this.errors = errors; + } + toJSON() { + const { + title, + detail, + errors + } = this; + return { + status: 400, + title, + detail, + errors, + }; + } +} const oapi = openapi({ openapi: '3.0.0', @@ -89,6 +165,78 @@ app.get('/', oapi.path({ res.send('Dit is de landing pagina van deze API'); }); +app.post('/input', oapi.path({ + description: 'Input method', + tags: [ + 'input' + ], + operationId: 'postInput', + responses: { + 200: { + description: 'Input', + content: { + 'text/plain': {} + }, + headers: { + [API_VERSION_HEADER_NAME]: oapi.headers(API_VERSION_HEADER_SCHEMA_NAME) + } + }, + 400: { + description: 'Invalid input', + content: BAD_REQUEST_RESPONSE, + headers: { + [API_VERSION_HEADER_NAME]: oapi.headers(API_VERSION_HEADER_SCHEMA_NAME) + } + } + } +}), (req, res) => { + /** + * @type {Array} + */ + const errors = []; + const queryInput = req.query.queryInput; + if (!queryInput) { + errors.push({ + in: 'query', + detail: 'Missing query parameter', + location: 'queryInput', + code: 'input.query.missing', + }) + } else { + let params; + if (typeof queryInput === 'string') { + params = [queryInput]; + } else { + params = queryInput; + } + for (const [index, param] of params.entries()) { + if (param !== '42') { + errors.push({ + in: 'query', + detail: 'Invalid query parameter. Expected 42, but was ' + param, + location: 'queryInput', + index, + code: 'input.query.invalid', + }); + } + } + } + + if (errors.length > 0) { + throw new BadRequestResponseWithErrors('title', 'detail', errors); + } + + res.send('Succesvolle response'); +}); + +app.use((err, req, res, next) => { + if (err instanceof BadRequestResponseWithErrors) { + return res.status(400).json(err.toJSON()); + } + + next(); +}); + app.listen(port, () => { console.log(`Example express app beschikbaar op poort ${port}`); });