diff --git a/test/OpenApiEndToEndTests/ModelValidation/swagger.g.json b/test/OpenApiEndToEndTests/ModelValidation/swagger.g.json new file mode 100644 index 0000000000..a32512b6d7 --- /dev/null +++ b/test/OpenApiEndToEndTests/ModelValidation/swagger.g.json @@ -0,0 +1,1264 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenApiTests", + "version": "1.0" + }, + "servers": [ + { + "url": "http://localhost" + } + ], + "paths": { + "/fingerprints": { + "get": { + "tags": [ + "fingerprints" + ], + "summary": "Retrieves a collection of fingerprints.", + "operationId": "getFingerprintCollection", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found fingerprints, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/fingerprintCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "fingerprints" + ], + "summary": "Retrieves a collection of fingerprints without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headFingerprintCollection", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + } + } + }, + "post": { + "tags": [ + "fingerprints" + ], + "summary": "Creates a new fingerprint.", + "operationId": "postFingerprint", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + } + ], + "requestBody": { + "description": "The attributes and relationships of the fingerprint to create.", + "content": { + "application/vnd.api+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/fingerprintPostRequestDocument" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "The fingerprint was successfully created, which resulted in additional changes. The newly created fingerprint is returned.", + "headers": { + "Location": { + "description": "The URL at which the newly created fingerprint can be retrieved.", + "required": true, + "schema": { + "type": "string", + "format": "uri" + } + } + }, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/fingerprintPrimaryResponseDocument" + } + } + } + }, + "204": { + "description": "The fingerprint was successfully created, which did not result in additional changes." + }, + "400": { + "description": "The query string is invalid or the request body is missing or malformed.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "403": { + "description": "Client-generated IDs cannot be used at this endpoint.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "A resource type in the request body is incompatible.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/fingerprints/{id}": { + "get": { + "tags": [ + "fingerprints" + ], + "summary": "Retrieves an individual fingerprint by its identifier.", + "operationId": "getFingerprint", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the fingerprint to retrieve.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found fingerprint.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/fingerprintPrimaryResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The fingerprint does not exist.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "fingerprints" + ], + "summary": "Retrieves an individual fingerprint by its identifier without returning it.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headFingerprint", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the fingerprint to retrieve.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The fingerprint does not exist." + } + } + }, + "patch": { + "tags": [ + "fingerprints" + ], + "summary": "Updates an existing fingerprint.", + "operationId": "patchFingerprint", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the fingerprint to update.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + } + ], + "requestBody": { + "description": "The attributes and relationships of the fingerprint to update. Omitted fields are left unchanged.", + "content": { + "application/vnd.api+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/fingerprintPatchRequestDocument" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "The fingerprint was successfully updated, which resulted in additional changes. The updated fingerprint is returned.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/fingerprintPrimaryResponseDocument" + } + } + } + }, + "204": { + "description": "The fingerprint was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The query string is invalid or the request body is missing or malformed.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The fingerprint or a related resource does not exist.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "A resource type or identifier in the request body is incompatible.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "delete": { + "tags": [ + "fingerprints" + ], + "summary": "Deletes an existing fingerprint by its identifier.", + "operationId": "deleteFingerprint", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the fingerprint to delete.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "The fingerprint was successfully deleted." + }, + "404": { + "description": "The fingerprint does not exist.", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "dataInResponse": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "minLength": 1, + "type": "string" + }, + "id": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "type", + "mapping": { + "fingerprints": "#/components/schemas/fingerprintDataInResponse" + } + }, + "x-abstract": true + }, + "errorLinks": { + "type": "object", + "properties": { + "about": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "errorObject": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/errorLinks" + } + ], + "nullable": true + }, + "status": { + "type": "string" + }, + "code": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "source": { + "allOf": [ + { + "$ref": "#/components/schemas/errorSource" + } + ], + "nullable": true + }, + "meta": { + "type": "object", + "additionalProperties": { }, + "nullable": true + } + }, + "additionalProperties": false + }, + "errorResponseDocument": { + "required": [ + "errors" + ], + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/errorObject" + } + } + }, + "additionalProperties": false + }, + "errorSource": { + "type": "object", + "properties": { + "pointer": { + "type": "string", + "nullable": true + }, + "parameter": { + "type": "string", + "nullable": true + }, + "header": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "fingerprintAttributesInPatchRequest": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "minLength": 1, + "type": "string" + }, + "userName": { + "maxLength": 18, + "minLength": 3, + "pattern": "^[a-zA-Z]+$", + "type": "string", + "nullable": true + }, + "creditCard": { + "type": "string", + "format": "credit-card", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "phone": { + "type": "string", + "format": "tel", + "nullable": true + }, + "age": { + "maximum": 123, + "minimum": 0, + "type": "integer", + "format": "int32", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "profilePicture": { + "type": "string", + "format": "uri", + "nullable": true + }, + "nextRevalidation": { + "allOf": [ + { + "$ref": "#/components/schemas/timeSpan" + } + ], + "nullable": true + }, + "validatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "validatedDateAt": { + "type": "string", + "format": "date", + "nullable": true + }, + "validatedTimeAt": { + "type": "string", + "format": "time", + "nullable": true + }, + "signature": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "fingerprintAttributesInPostRequest": { + "required": [ + "lastName", + "tags" + ], + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "minLength": 1, + "type": "string" + }, + "userName": { + "maxLength": 18, + "minLength": 3, + "pattern": "^[a-zA-Z]+$", + "type": "string", + "nullable": true + }, + "creditCard": { + "type": "string", + "format": "credit-card", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "phone": { + "type": "string", + "format": "tel", + "nullable": true + }, + "age": { + "maximum": 123, + "minimum": 0, + "type": "integer", + "format": "int32", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "profilePicture": { + "type": "string", + "format": "uri", + "nullable": true + }, + "nextRevalidation": { + "allOf": [ + { + "$ref": "#/components/schemas/timeSpan" + } + ], + "nullable": true + }, + "validatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "validatedDateAt": { + "type": "string", + "format": "date", + "nullable": true + }, + "validatedTimeAt": { + "type": "string", + "format": "time", + "nullable": true + }, + "signature": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "fingerprintAttributesInResponse": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "minLength": 1, + "type": "string" + }, + "userName": { + "maxLength": 18, + "minLength": 3, + "pattern": "^[a-zA-Z]+$", + "type": "string", + "nullable": true + }, + "creditCard": { + "type": "string", + "format": "credit-card", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "phone": { + "type": "string", + "format": "tel", + "nullable": true + }, + "age": { + "maximum": 123, + "minimum": 0, + "type": "integer", + "format": "int32", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "profilePicture": { + "type": "string", + "format": "uri", + "nullable": true + }, + "nextRevalidation": { + "allOf": [ + { + "$ref": "#/components/schemas/timeSpan" + } + ], + "nullable": true + }, + "validatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "validatedDateAt": { + "type": "string", + "format": "date", + "nullable": true + }, + "validatedTimeAt": { + "type": "string", + "format": "time", + "nullable": true + }, + "signature": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "fingerprintCollectionResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/linksInResourceCollectionDocument" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fingerprintDataInResponse" + } + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dataInResponse" + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "type": "object", + "nullable": true + } + } + }, + "additionalProperties": false + }, + "fingerprintDataInPatchRequest": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/fingerprintResourceType" + }, + "id": { + "minLength": 1, + "type": "string" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/fingerprintAttributesInPatchRequest" + } + ] + } + }, + "additionalProperties": false + }, + "fingerprintDataInPostRequest": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/fingerprintResourceType" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/fingerprintAttributesInPostRequest" + } + ] + } + }, + "additionalProperties": false + }, + "fingerprintDataInResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInResponse" + }, + { + "required": [ + "links" + ], + "type": "object", + "properties": { + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/fingerprintAttributesInResponse" + } + ] + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/linksInResourceData" + } + ] + }, + "meta": { + "type": "object", + "additionalProperties": { + "type": "object", + "nullable": true + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "fingerprintPatchRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/fingerprintDataInPatchRequest" + } + ] + } + }, + "additionalProperties": false + }, + "fingerprintPostRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/fingerprintDataInPostRequest" + } + ] + } + }, + "additionalProperties": false + }, + "fingerprintPrimaryResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/linksInResourceDocument" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/fingerprintDataInResponse" + } + ] + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dataInResponse" + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "type": "object", + "nullable": true + } + } + }, + "additionalProperties": false + }, + "fingerprintResourceType": { + "enum": [ + "fingerprints" + ], + "type": "string", + "additionalProperties": false + }, + "linksInResourceCollectionDocument": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "minLength": 1, + "type": "string" + }, + "describedby": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceData": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceDocument": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "minLength": 1, + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, + "timeSpan": { + "type": "object", + "properties": { + "ticks": { + "type": "integer", + "format": "int64" + }, + "days": { + "type": "integer", + "format": "int32", + "readOnly": true + }, + "hours": { + "type": "integer", + "format": "int32", + "readOnly": true + }, + "milliseconds": { + "type": "integer", + "format": "int32", + "readOnly": true + }, + "microseconds": { + "type": "integer", + "format": "int32", + "readOnly": true + }, + "nanoseconds": { + "type": "integer", + "format": "int32", + "readOnly": true + }, + "minutes": { + "type": "integer", + "format": "int32", + "readOnly": true + }, + "seconds": { + "type": "integer", + "format": "int32", + "readOnly": true + }, + "totalDays": { + "type": "number", + "format": "double", + "readOnly": true + }, + "totalHours": { + "type": "number", + "format": "double", + "readOnly": true + }, + "totalMilliseconds": { + "type": "number", + "format": "double", + "readOnly": true + }, + "totalMicroseconds": { + "type": "number", + "format": "double", + "readOnly": true + }, + "totalNanoseconds": { + "type": "number", + "format": "double", + "readOnly": true + }, + "totalMinutes": { + "type": "number", + "format": "double", + "readOnly": true + }, + "totalSeconds": { + "type": "number", + "format": "double", + "readOnly": true + } + }, + "additionalProperties": false + } + } + } +} \ No newline at end of file diff --git a/test/OpenApiTests/ModelValidation/Fingerprint.cs b/test/OpenApiTests/ModelValidation/Fingerprint.cs new file mode 100644 index 0000000000..9a0dcbc595 --- /dev/null +++ b/test/OpenApiTests/ModelValidation/Fingerprint.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace OpenApiTests.ModelValidation; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "OpenApiTests.ModelValidation")] +public sealed class Fingerprint : Identifiable +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + [Required(ErrorMessage = "Last name is required")] + public string LastName { get; set; } = default!; + + [Attr] + [StringLength(18, MinimumLength = 3)] + [RegularExpression("^[a-zA-Z]+$", ErrorMessage = "Only letters are allowed")] + public string? UserName { get; set; } + + [Attr] + [CreditCard] + public string? CreditCard { get; set; } + + [Attr] + [EmailAddress] + public string? Email { get; set; } + + [Attr] + [Phone] + public string? Phone { get; set; } + + [Attr] + [Range(0, 123)] + public int? Age { get; set; } + + [Attr] +#if NET8_0_OR_GREATER + [Length(0, 10, ErrorMessage = "{0} length must be between {2} and {1}.")] +#endif + public List Tags { get; set; } = []; + + [Attr] + [Url] + public Uri? ProfilePicture { get; set; } + + [Attr] + [Range(typeof(TimeSpan), "01:00", "05:00")] + public TimeSpan? NextRevalidation { get; set; } + + [Attr] + public DateTime? ValidatedAt { get; set; } + + [Attr] + public DateOnly? ValidatedDateAt { get; set; } + + [Attr] + public TimeOnly? ValidatedTimeAt { get; set; } + + [Attr] +#if NET8_0_OR_GREATER + [Base64String] +#endif + public string? Signature { get; set; } +} diff --git a/test/OpenApiTests/ModelValidation/ModelValidationDbContext.cs b/test/OpenApiTests/ModelValidation/ModelValidationDbContext.cs new file mode 100644 index 0000000000..13351cfbe2 --- /dev/null +++ b/test/OpenApiTests/ModelValidation/ModelValidationDbContext.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace OpenApiTests.ModelValidation; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ModelValidationDbContext(DbContextOptions options) : TestableDbContext(options) +{ + public DbSet Fingerprints => Set(); +} diff --git a/test/OpenApiTests/ModelValidation/ModelValidationFakers.cs b/test/OpenApiTests/ModelValidation/ModelValidationFakers.cs new file mode 100644 index 0000000000..3b32dd6ce0 --- /dev/null +++ b/test/OpenApiTests/ModelValidation/ModelValidationFakers.cs @@ -0,0 +1,31 @@ +using Bogus; +using JetBrains.Annotations; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true + +namespace OpenApiTests.ModelValidation; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ModelValidationFakers : FakerContainer +{ + private readonly Lazy> _lazyFingerprintFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(fingerprint => fingerprint.FirstName, faker => faker.Person.FirstName) + .RuleFor(fingerprint => fingerprint.LastName, faker => faker.Person.LastName) + .RuleFor(fingerprint => fingerprint.UserName, faker => faker.Random.String2(3, 18)) + .RuleFor(fingerprint => fingerprint.CreditCard, faker => faker.Finance.CreditCardNumber()) + .RuleFor(fingerprint => fingerprint.Email, faker => faker.Person.Email) + .RuleFor(fingerprint => fingerprint.Phone, faker => faker.Person.Phone) + .RuleFor(fingerprint => fingerprint.Age, faker => faker.Random.Number(0, 123)) + .RuleFor(fingerprint => fingerprint.Tags, faker => faker.Make(faker.Random.Number(0, 10), () => faker.Random.String2(3))) + .RuleFor(fingerprint => fingerprint.ProfilePicture, faker => new Uri(faker.Image.LoremFlickrUrl())) + .RuleFor(fingerprint => fingerprint.NextRevalidation, faker => TimeSpan.FromMinutes(faker.Random.Number(1, 5))) + .RuleFor(fingerprint => fingerprint.ValidatedAt, faker => faker.Date.Recent()) + .RuleFor(fingerprint => fingerprint.ValidatedDateAt, faker => DateOnly.FromDateTime(faker.Date.Recent())) + .RuleFor(fingerprint => fingerprint.ValidatedTimeAt, faker => TimeOnly.FromDateTime(faker.Date.Recent())) + .RuleFor(fingerprint => fingerprint.Signature, faker => Convert.ToBase64String(faker.Random.Bytes(10)))); + + public Faker Fingerprint => _lazyFingerprintFaker.Value; +} diff --git a/test/OpenApiTests/ModelValidation/ModelValidationTests.cs b/test/OpenApiTests/ModelValidation/ModelValidationTests.cs new file mode 100644 index 0000000000..9129011e96 --- /dev/null +++ b/test/OpenApiTests/ModelValidation/ModelValidationTests.cs @@ -0,0 +1,256 @@ +using System.Text.Json; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiTests.ModelValidation; + +public sealed class ModelValidationTests : IClassFixture, ModelValidationDbContext>> +{ + private readonly OpenApiTestContext, ModelValidationDbContext> _testContext; + + public ModelValidationTests(OpenApiTestContext, ModelValidationDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.SwaggerDocumentOutputDirectory = "test/OpenApiEndToEndTests/ModelValidation"; + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task String(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.firstName").With(firstNameEl => + { + firstNameEl.Should().HaveProperty("type", "string"); + firstNameEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Non_nullable_string(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.lastName").With(lastNameEl => + { + lastNameEl.Should().HaveProperty("minLength", 1); + lastNameEl.Should().HaveProperty("type", "string"); + lastNameEl.Should().NotContainPath("nullable"); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task String_length_and_regex(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.userName").With(userNameEl => + { + userNameEl.Should().HaveProperty("maxLength", 18); + userNameEl.Should().HaveProperty("minLength", 3); + userNameEl.Should().HaveProperty("pattern", "^[a-zA-Z]+$"); + userNameEl.Should().HaveProperty("type", "string"); + userNameEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Credit_card(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.creditCard").With(creditCardEl => + { + creditCardEl.Should().HaveProperty("type", "string"); + creditCardEl.Should().HaveProperty("format", "credit-card"); + creditCardEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Email(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.email").With(emailEl => + { + emailEl.Should().HaveProperty("type", "string"); + emailEl.Should().HaveProperty("format", "email"); + emailEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Phone(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.phone").With(phoneEl => + { + phoneEl.Should().HaveProperty("type", "string"); + phoneEl.Should().HaveProperty("format", "tel"); + phoneEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Age(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.age").With(ageEl => + { + ageEl.Should().HaveProperty("maximum", 123); + ageEl.Should().HaveProperty("minimum", 0); + ageEl.Should().HaveProperty("type", "integer"); + ageEl.Should().HaveProperty("format", "int32"); + ageEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Tags(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.tags").With(tagsEl => + { + tagsEl.Should().HaveProperty("type", "array"); + tagsEl.Should().ContainPath("items").With(itemsEl => + { + itemsEl.Should().HaveProperty("type", "string"); + // TODO: no length constraint? + }); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Profile_picture(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.profilePicture").With(profilePictureEl => + { + profilePictureEl.Should().HaveProperty("type", "string"); + profilePictureEl.Should().HaveProperty("format", "uri"); + profilePictureEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Next_revalidation(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.nextRevalidation").With(nextRevalidationEl => + { + // TODO: TimeSpan format is an akward object with all the TimeSpan public properties. + nextRevalidationEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Validated_at(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.validatedAt").With(validatedAtEl => + { + validatedAtEl.Should().HaveProperty("type", "string"); + validatedAtEl.Should().HaveProperty("format", "date-time"); + validatedAtEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Validated_date_at(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.validatedDateAt").With(validatedDateAtEl => + { + validatedDateAtEl.Should().HaveProperty("type", "string"); + validatedDateAtEl.Should().HaveProperty("format", "date"); + validatedDateAtEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Validated_time_at(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.validatedTimeAt").With(validatedTimeAtEl => + { + validatedTimeAtEl.Should().HaveProperty("type", "string"); + validatedTimeAtEl.Should().HaveProperty("format", "time"); + validatedTimeAtEl.Should().HaveProperty("nullable", true); + }); + } + + [Theory] + [MemberData(nameof(ModelNames))] + public async Task Signature(string modelName) + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath($"components.schemas.{modelName}.properties.signature").With(signatureEl => + { + signatureEl.Should().HaveProperty("type", "string"); + // TODO: no format? + signatureEl.Should().HaveProperty("nullable", true); + }); + } + + public static TheoryData ModelNames => + new() + { + "fingerprintAttributesInPatchRequest", + "fingerprintAttributesInPostRequest", + "fingerprintAttributesInResponse" + }; +}