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}`); }); 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..82d4842c --- /dev/null +++ b/examples/quarkus/src/main/java/org/acme/BadRequestProblem.java @@ -0,0 +1,84 @@ +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(nullable = true, description = "A locator for the offending value") + @JsonInclude(value = JsonInclude.Include.NON_NULL) + 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; + + @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, 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 forBody(String location, String detail, String code) { + return new BadRequestDetails(BadRequestLocation.Body, detail, location, null, code); + } + } + + 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..ecc6a7f9 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,81 @@ 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.ArrayList; +import java.util.List; @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("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 = queryInputParam.iterator(); + for (var index = 0; queryParams.hasNext(); index++) { + var param = queryParams.next(); + if (!param.equals("42")) { + 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 new file mode 100644 index 00000000..d0ed9f0e --- /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 + 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 new file mode 100644 index 00000000..9655af0b --- /dev/null +++ b/linter/testcases/error-type-bad-invalid-input/openapi.json @@ -0,0 +1,229 @@ +{ + "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": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } + } + } + }, + "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..9954dc76 --- /dev/null +++ b/linter/testcases/error-type-bad-request/expected-output.txt @@ -0,0 +1,11 @@ + +/testcases/error-type-bad-request/openapi.json + 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 + +✖ 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 new file mode 100644 index 00000000..42d20e30 --- /dev/null +++ b/linter/testcases/error-type-bad-request/openapi.json @@ -0,0 +1,584 @@ +{ + "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": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } + } + } + }, + "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": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "location": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } + } + } + }, + "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": "array", + "items": { + "type": "object", + "properties": { + "in": { "type": "string" }, + "code": { "type": "string" }, + "detail": { "type": "string" }, + "index": { "type": "number" } + } + } + } + }, + "required": ["status", "title", "detail", "errors"], + "additionalProperties": false + } + } + } + } + }, + "security": [ + { + "default": [] + } + ] + } + }, + "/properties-verkeerd-formaat-in": { + "get": { + "tags": [ + "resource" + ], + "description": "Resource", + "operationId": "getPropertiesVerkeerdFormatIn", + "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": "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" } + } + } + } + }, + "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..e4e1d90c 100644 --- a/media/linter.yaml +++ b/media/linter.yaml @@ -213,6 +213,83 @@ 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: + items: + type: object + properties: + properties: + type: object + properties: + in: + type: object + properties: + type: + const: string + detail: + type: object + properties: + type: + const: string + location: + type: object + properties: + 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: + - items + required: + - errors + nlgov:property-casing: severity: warn given: diff --git a/sections/designRules.md b/sections/designRules.md index 139554b9..0737972b 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