diff --git a/docs/checklist.md b/docs/checklist.md deleted file mode 100644 index 056c6f1c..00000000 --- a/docs/checklist.md +++ /dev/null @@ -1,116 +0,0 @@ -# REST API Testing Checklist - -## URLs - -- `/nouns` - URLs are normally 'plural' nouns, if 'noun' does not exist then [404](https://httpstatuses.com/404) will result -- `/nouns/` - a trailing slash means `nouns` with (`/`) 'missing value', so will usually result in a [404](https://httpstatuses.com/404) -- `/nouns/{guid}` - `{guid}` is some sort of unique id or GUID, varies per system, if item is not found then status [404](https://httpstatuses.com/404) returned - - -Query Arguments: - -- `?id=1` query arguments are often supported for filtering e.g. `/nouns?status=true` would return all `noun` with a status of true -- query arguments are separated by `&` e.g. `?id=1&status=true` -- since this only allows exact matching, some systems have custom query argument approaches - - e.g. `?id=[>=]1[and][<=]4`, `?id=1-4` this will be system specific -- also `&` is not 'mandatory' it is 'customary', anything after `?` is the query string so could be parsed however the API defines - -## Status Codes - -All responses contain a [status code](https://httpstatuses.com/). - -- We expect the status code to match the type of response e.g. if there was an error processing a request then we would expect a 4xx or 5xx response, we would not expect a 2xx response. - -General Status Code Ranges: - -- 1xx - Information response (100, 101, 102) -- 2xx - Success response (200 - 226) -- 3xx - Redirection response (300 - 308) -- 4xx - Client Error in Request (400 - 499) -- 5xx - Server Error (500 - 599) - -## Verbs - -Verbs use resources e.g. URLs. Some general status code rules apply: - -- Non-Existant - [404](https://httpstatuses.com/404) - - if a request is made for a resource that does not exist then a [404](https://httpstatuses.com/404) status code is returned -- Not-Authenticated - [401](https://httpstatuses.com/401) - - if a request is made for a resource that requires Authentication, and the request is not authenticated then [401](https://httpstatuses.com/401) status code is returned -- Not-Authorised - [403](https://httpstatuses.com/403) - - if a request is made for a resource that requires an Authenticated request with a level of authorisation, and the request is not authorised then [403](https://httpstatuses.com/403) status code is returned -- Method Not Allowed - [405](https://httpstatuses.com/405) - - if a resource exists, but the method (e.g. `GET`, `PUT`) does not apply, then a [405](https://httpstatuses.com/405) status code is returned - - the `OPTIONS` request can usually be issued on a resource to find out what methods apply - - -- `GET` - - is cacheable - - when resource is found - - then a [200](https://httpstatuses.com/200) status code is returned - - the body of the response contains the details fetched about the resource - - the format of the response (e.g JSON, XML) is based on the MIME type in the Accept header of the request - - if no Accept header was sent in the request then the default for the system will be used - - when resource is not found [404](https://httpstatuses.com/404) -- `HEAD` - - identical to `GET` without a body -- `PUT` - - is idempotent (same result each time) - - for an Update that means 'replace', and all fields presented - - for a Create that means 'insert', and all fields presented (it may not be possible to create items with PUT if GUIDs or IDs are used in the entity) -- `POST` - - is not idempotent (could have a different result, e.g. partial payloads, concurrent updates) - - for an update that means 'update', and partial payloads can be presented, with unmentioned fields not updated - - for a create that means 'insert', and partial payloads can be presented, with unmentioned fields using defaults or auto-generated values - -### PUT vs POST - -Not all systems support PUT and POST in the same way. - -So the 'rules' for PUT and POST tend to be contextual in applications. - -If it is to be REST compliant then the main difference is Idemopotent (PUT) vs Not Necessarily Idempotent (POST). But sometimes systems do not comply with this. - -Remember the REST descriptions are 'guidelines'. APIs can still 'work' when they don't follow the guidelines exactly , the API documentation just has to be clear on how the API operates. - - - -## Content Negotiation Headers - -- `Accept` - - responses represented using the Media specified - - `application/json` - - `application/xml` - - `*/*` - default provided - - Media types processed in preference order in accept header - - `application/xml, application/json, */*` - - are `;q=0.9` priorities supported? - - when no accept header in request then default is provided in response - - `406` status code if cannot supply an accepted type ([406](https://httpstatuses.com/406)) - -- `Content-Type` - - `415` status code if content-type not supported ([415](https://httpstatuses.com/415)) e.g. XML supplied, but system only accepts JSON - - If the `Content-Type` header is not present then systems might not issue a `415` they may instead, try to derive the content-type from the payload, and potentially throw a `400` error if they can't parse it. - -## References - -- Roy Thomas Fielding [REST Thesis](https://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm) -- (https://tools.ietf.org/html/rfc7230) -- Hypertext Transfer Protocol (HTTP/1.1) - [original](https://tools.ietf.org/html/rfc2616) - - [(HTTP/1.1): Message Syntax and Routing](https://tools.ietf.org/html/rfc7230) - - [(HTTP/1.1): Semantics and Content](https://tools.ietf.org/html/rfc7231) - - [(HTTP/1.1): Conditional Requests](https://tools.ietf.org/html/rfc7232) - - [(HTTP/1.1): Range Requests](https://tools.ietf.org/html/rfc7233) - - [(HTTP/1.1): Caching](https://tools.ietf.org/html/rfc7234) - - [(HTTP/1.1): Authentication](https://tools.ietf.org/html/rfc7235) - - [PATCH Method for HTTP](https://tools.ietf.org/html/rfc5789) -- [IANA Media Types Registry](https://www.iana.org/assignments/media-types/media-types.xhtml) -- [restfulapi.net](https://restfulapi.net/) -- [httpstatuses.com](https://httpstatuses.com) -- [Mozilla HTTP MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP) -- [Open API Spec Group](https://www.openapis.org/) -- [Open API Spec](https://github.com/OAI/OpenAPI-Specification) - -Security: - -- [OWASP REST API Security Cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html) \ No newline at end of file diff --git a/docs/commonissues.md b/docs/commonissues.md deleted file mode 100644 index 6b195634..00000000 --- a/docs/commonissues.md +++ /dev/null @@ -1,34 +0,0 @@ -# Common issues - -## URLs - -- `/noun` is singular, or mixed, causing confusion -- `/nouns/` does not throw 404 - -## Query Params - -- query params being used for sensitive data that might appear in server logs -- query params allowing access to content not normally accessible to user e.g. paging, or specific ids - -## Status Codes - -- status codes often use the most generic, when a more specific would capture semantics better, e.g. 400 used all the time, 200 used when 204 might be better - -## Verbs - -- post, put, patch - used without properly discriminating between them -- `404` returned instead of `405` when endpoint exists, but not configured for a verb - -## Payloads - --Malformed payloads typically trigger errors in the surrounding frameworks, not the api, but if not handled properly can cause server errors - -## Headers - -- Duplicate headers can cause systems to only validate contents of first header, but use the second header, allowing bypass of validation -- Extra / Unrecognised headers can sometimes server error APIs - - -## Conversion - -- various technologies can mean conversion issues slip through e.g. JSON Integers to floats, boolean to string. Check field types. \ No newline at end of file diff --git a/docs/httpyac/httpyacexample.http b/docs/httpyac/httpyacexample.http deleted file mode 100644 index 83b3b04e..00000000 --- a/docs/httpyac/httpyacexample.http +++ /dev/null @@ -1,166 +0,0 @@ -Apichallenges HttpYac Examples - -A literate approach to API Testing. It is tempting to view as - -Since API Challenges can be run locally or online we can use different URL host domains to access it. - - -- https://apichallenges.eviltester.com -- http://localhost:4567 - - -I'll be testing against the cloud based version. - -/* - -Text in the .http file can be commented out like this to avoid interfering with the HTTP requests. - -But adding the ### to separate requests and documentation seems to work well enough. - -*/ - - -### - -@host=https://apichallenges.eviltester.com - -/* - ** Enable to allow all requests to go through a proxy ** - - to get proxy to work, make sure the "Proxy Support" is set to on - in VS Code Preferences > Settings - 'override' doesn't seem to work - */ -// # @proxy http://127.0.0.1:8080 - -### @Description Example call to GET https://apichallenges.eviltester.com/todos because it uses the @host -GET /todos - -### - -## Create a Session - -We can create a session in the challenges by issuing a request. - -To see the console.info: - -- Help > Show All Commands (Ctrl + Shift + P) -- Output: Show output channels... -- httpyac - console - -This will easily show the state of any assertions e.g. `?? status == 200` - - -Click the HttpYac icon on the left extensions bar to see history, variables etc. - -Or enable via: - -- Help > Show All Commands -- View: show httpyac - -This will show the history, all variables, etc. - -A very useful feature is to click a request then use the command "HttpYac - Generate Code" by default this will generate a cURL command in a new editor window. - -NOTE: when you click on an item in the history tree it is automatically copied to the clipboard. e.g. useful for finding a header value. - -NOTE: if you double click an item from the history then the whole request/response loaded into an editor tab. - - -### @Description Create a Challenger session with POST to reuse -POST /challenger - -{{ - exports.xChallenger=response.headers['x-challenger'] -}} - - -### - -## TODOs - -To get the list of todos from the environment we can issue a request on the `/todos` endpoint. - -The `@name` will show the response as a variable in HTTPYAC extension view. - -### -# @name get_all_todos -# @Description GET all todos -GET /todos -x-challenger: {{xChallenger}} - -?? status == 200 - -{{ - firstTodoId=response.parsedBody.todos[0].id - missingTodo = (response.parsedBody.todos.reduce( - function(prev, current) { - return (prev && prev.id > current.id) - ? prev : current - }).id) + 1 - allTodosId= [] - allTodoIds = response.parsedBody.todos.map(todo => todo.id) -}} - -### -# @Description GET the first todo from the response - -GET /todos/{{firstTodoId}} -x-challenger: {{xChallenger}} - -?? status == 200 - -# @Description GET a todo that does not exist from the Get all todos response - -GET /todos/{{missingTodo}} -x-challenger: {{xChallenger}} - -?? status == 404 - - -### -# @Description A `HEAD` request will return the headers that would be sent back from a `GET` request. - -HEAD /todos -x-challenger: {{xChallenger}} - -?? status == 200 - -GET /challenges -x-challenger: {{xChallenger}} - -?? status == 200 - -GET /todo -x-challenger: {{xChallenger}} - -?? status == 404 - - -### -# @Description Save a session -# - -GET /challenger/{{xChallenger}} -x-challenger: {{xChallenger}} - -?? status == 200 - ->>! ./savedChallenger.json - - -GET /challenger/database/{{xChallenger}} -x-challenger: {{xChallenger}} - -?? status == 200 - ->>! ./todosDatabase.json - - -### -# @Description Delete all todos -# @forceref get_all_todos -# @loop for id of allTodoIds - -DELETE /todos/{{id}} -x-challenger: {{xChallenger}} diff --git a/docs/httpyac/readme.md b/docs/httpyac/readme.md deleted file mode 100644 index 48a6013c..00000000 --- a/docs/httpyac/readme.md +++ /dev/null @@ -1,9 +0,0 @@ -# HttpYac - -[HttpYac](https://httpyac.github.io/) is an interesting and very different HTTP REST Client. - -Distributed as a Visual Studio Code extension through the VSCode marketplace, it can turn an `.http` text file into an HTTP REST GUI. - -The examples repo is a good source to experiment to learn more: - -https://github.com/httpyac/httpyac.github.io/tree/main/examples \ No newline at end of file diff --git a/docs/k6/script.js b/docs/k6/script.js deleted file mode 100644 index 9b76dc13..00000000 --- a/docs/k6/script.js +++ /dev/null @@ -1,121 +0,0 @@ -import http from 'k6/http'; -import { check, sleep } from 'k6'; - -export const options = { - - scenarios: { - my_scenario1: { - executor: 'constant-arrival-rate', - duration: '60s', // total duration - preAllocatedVUs: 100, // to allocate runtime resources preAll - rate: 100, // number of constant iterations given `timeUnit` - timeUnit: '1s', - }, - }, - -// // A number specifying the number of VUs to run concurrently. -// vus: 100, -// // A string specifying the total duration of the test run. -// duration: '20s', - - - // Uncomment this section to enable the use of Browser API in your tests. - // - // See https://grafana.com/docs/k6/latest/using-k6-browser/running-browser-tests/ to learn more - // about using Browser API in your test scripts. - // - // scenarios: { - // // The scenario name appears in the result summary, tags, and so on. - // // You can give the scenario any name, as long as each name in the script is unique. - // ui: { - // // Executor is a mandatory parameter for browser-based tests. - // // Shared iterations in this case tells k6 to reuse VUs to execute iterations. - // // - // // See https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/ for other executor types. - // executor: 'shared-iterations', - // options: { - // browser: { - // // This is a mandatory parameter that instructs k6 to launch and - // // connect to a chromium-based browser, and use it to run UI-based - // // tests. - // type: 'chromium', - // }, - // }, - // }, - // } -}; - -// The function that defines VU logic. -// -// See https://grafana.com/docs/k6/latest/examples/get-started-with-k6/ to learn more -// about authoring k6 scripts. -// -const origin = "http://localhost:4567"; - -export default function() { - - const itemsJson = getItems(); - - // random item id - const randomIndex = Math.floor(Math.random()*itemsJson.items.length) - const itemId = itemsJson.items[randomIndex].id; - if((Math.random()*100)<5){ - // 5% chance we will get an item - const itemJson = getItem(itemId); - } - if((Math.random()*100)<10){ - // 10% chance that we will delete something - deleteItem(itemId); - } - if(itemsJson.items.length<80){ - createRandomItem(); - } -} - -function getItems(){ - const response = http.get(`${origin}/simpleapi/items`); - check(response, { 'get items status was 200': (r) => r.status == 200 }); - if(response.status!=200){ - console.log(response.body) - } - return response.json(); -} - -function getItem(id){ - const response = http.get(`${origin}/simpleapi/items/${id}`); - check(response, { 'get item status was 200': (r) => r.status == 200 }); - if(response.status!=200){ - console.log(response.body) - } - return response.json(); -} - -function deleteItem(id){ - const response = http.del(`${origin}/simpleapi/items/${id}`); - check(response, { 'delete item status was 200': (r) => r.status == 200 }); - if(response.status!=200){ - console.log(response.body) - } -} - -function createRandomItem(){ - const types = ["cd", "book", "dvd", "blu-ray"] - const randomType = Math.floor(Math.random()*types.length) - const isbn = ("" + Math.random()).substring(2, 8) + ("" + Math.random()).substring(2, 8) + ("" + Math.random()).substring(2, 3) - const anItem = { - "type": types[randomType], - "isbn13": isbn, - "price" : ((Math.random()*100+10)+"").substring(0,5) - } - //console.log(anItem); - const response = http.post(`${origin}/simpleapi/items`, JSON.stringify(anItem), - { - headers: { 'Content-Type': 'application/json' }, - } - ) - - if(response.status==400){ - console.log(response.body) - } - check(response, { 'post item status was 201': (r) => r.status == 201 }); -} \ No newline at end of file diff --git a/docs/mockapis/mockoon/fromhell/fromhell.json b/docs/mockapis/mockoon/fromhell/fromhell.json deleted file mode 100644 index 154e6fc5..00000000 --- a/docs/mockapis/mockoon/fromhell/fromhell.json +++ /dev/null @@ -1,414 +0,0 @@ -{ - "uuid": "c270c729-19a5-4034-bcf3-3f4eb77feaff", - "lastMigration": 33, - "name": "Fromhell", - "endpointPrefix": "", - "latency": 0, - "port": 3001, - "hostname": "", - "folders": [ - { - "uuid": "ee634c9b-a1a6-493b-8034-a3639595d3cc", - "name": "Malformed", - "children": [ - { - "type": "route", - "uuid": "4509bb0d-ad7f-45c2-94f6-b4d89794e52d" - }, - { - "type": "route", - "uuid": "fe43c8fe-4899-4f18-8f01-e47e7ab05efd" - } - ] - }, - { - "uuid": "d65eddce-d969-4c6c-96f4-c4bf3dcecba0", - "name": "Mismatched Content Type", - "children": [ - { - "type": "route", - "uuid": "bb2ddbaa-ab2e-409a-94ef-ab3ae683ef7f" - }, - { - "type": "route", - "uuid": "06b007c4-c6e4-4c47-a3b2-a72e7ae07032" - } - ] - }, - { - "uuid": "f6c11405-8bc5-455e-b52e-38afc6a4cca2", - "name": "Good", - "children": [ - { - "type": "route", - "uuid": "3ff3c506-a937-4cd4-9588-78794aa7e30d" - }, - { - "type": "route", - "uuid": "8b16c6f1-0687-4fef-92da-810dd69dc374" - } - ] - }, - { - "uuid": "469e6d4a-b5cf-4afe-b1e0-10fa423786b4", - "name": "HTTP Semantics", - "children": [] - } - ], - "routes": [ - { - "uuid": "4509bb0d-ad7f-45c2-94f6-b4d89794e52d", - "type": "http", - "documentation": "", - "method": "get", - "endpoint": "malformed/json", - "responses": [ - { - "uuid": "b09691b8-c53d-4182-91c7-3d9239bdee96", - "body": "[{\"id\":\"57ab8bfa-dcad-4649-9442-3b13010e852d\",\"username\":\"Damaris76\"},{\"id\":\"e88067f0-b028-4310-8a33-8cbc03203249\",\"username\":\"Modesto_Reichel61\"},{\"id\":\"fee23d87-721f-4ffa-a598-e350e13385b2\",\"username\":\"Abagail_Shanahan\"},{\"id\":\"987fc185-7cc6-4883-a5f4-3273b1045155\",\"username\":\"Joaquin_Funk12\"},{\"id\":\"0c2744bb-881a-41c0-a577-603b052485d9\",\"username\":\"Maverick.Corkery74\"}", - "latency": 0, - "statusCode": 200, - "label": "Json response with missing collection termination", - "headers": [ - { - "key": "content-type", - "value": "application/json" - } - ], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null, - "streamingMode": null, - "streamingInterval": 0 - }, - { - "uuid": "fe43c8fe-4899-4f18-8f01-e47e7ab05efd", - "type": "http", - "documentation": "", - "method": "get", - "endpoint": "malformed/xml", - "responses": [ - { - "uuid": "5e87e7b0-2d77-448f-a3af-34a5354a0fe7", - "body": "\r\n \r\n <0>\r\n 57ab8bfa-dcad-4649-9442-3b13010e852d\r\n Damaris76\r\n \r\n\r\n", - "latency": 0, - "statusCode": 200, - "label": "XML response with missing root terminator", - "headers": [ - { - "key": "content-type", - "value": "application/xml" - } - ], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null, - "streamingMode": null, - "streamingInterval": 0 - }, - { - "uuid": "35cfdf52-f6b4-4ea1-8052-09e1a5247adc", - "type": "http", - "documentation": "", - "method": "get", - "endpoint": "version", - "responses": [ - { - "uuid": "1227aea1-63f4-472a-9c6c-88467dc299eb", - "body": "{\"version\":\"6\"}", - "latency": 0, - "statusCode": 200, - "label": "", - "headers": [], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null, - "streamingMode": null, - "streamingInterval": 0 - }, - { - "uuid": "bb2ddbaa-ab2e-409a-94ef-ab3ae683ef7f", - "type": "http", - "documentation": "", - "method": "get", - "endpoint": "mismatch/content-type/json-xml", - "responses": [ - { - "uuid": "3f2c79f6-8153-4002-beda-1aedf4a366c5", - "body": "\r\n \r\n <0>\r\n 57ab8bfa-dcad-4649-9442-3b13010e852d\r\n Damaris76\r\n \r\n <1>\r\n e88067f0-b028-4310-8a33-8cbc03203249\r\n Modesto_Reichel61\r\n \r\n <2>\r\n fee23d87-721f-4ffa-a598-e350e13385b2\r\n Abagail_Shanahan\r\n \r\n <3>\r\n 987fc185-7cc6-4883-a5f4-3273b1045155\r\n Joaquin_Funk12\r\n \r\n <4>\r\n 0c2744bb-881a-41c0-a577-603b052485d9\r\n Maverick.Corkery74\r\n \r\n ", - "latency": 0, - "statusCode": 200, - "label": "Content Type states JSON but is actually XML", - "headers": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null, - "streamingMode": null, - "streamingInterval": 0 - }, - { - "uuid": "3ff3c506-a937-4cd4-9588-78794aa7e30d", - "type": "http", - "documentation": "", - "method": "get", - "endpoint": "good/json", - "responses": [ - { - "uuid": "05177962-604a-4544-a38e-f9373babd8b2", - "body": "[{\"id\":\"57ab8bfa-dcad-4649-9442-3b13010e852d\",\"username\":\"Damaris76\"},{\"id\":\"e88067f0-b028-4310-8a33-8cbc03203249\",\"username\":\"Modesto_Reichel61\"},{\"id\":\"fee23d87-721f-4ffa-a598-e350e13385b2\",\"username\":\"Abagail_Shanahan\"},{\"id\":\"987fc185-7cc6-4883-a5f4-3273b1045155\",\"username\":\"Joaquin_Funk12\"},{\"id\":\"0c2744bb-881a-41c0-a577-603b052485d9\",\"username\":\"Maverick.Corkery74\"}]", - "latency": 0, - "statusCode": 200, - "label": "Good JSON Response", - "headers": [ - { - "key": "content-type", - "value": "application/json" - } - ], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null, - "streamingMode": null, - "streamingInterval": 0 - }, - { - "uuid": "8b16c6f1-0687-4fef-92da-810dd69dc374", - "type": "http", - "documentation": "", - "method": "get", - "endpoint": "good/xml", - "responses": [ - { - "uuid": "d462bfd3-38f6-4dce-9f61-b82d3223022f", - "body": "\r\n \r\n <0>\r\n 57ab8bfa-dcad-4649-9442-3b13010e852d\r\n Damaris76\r\n \r\n <1>\r\n e88067f0-b028-4310-8a33-8cbc03203249\r\n Modesto_Reichel61\r\n \r\n <2>\r\n fee23d87-721f-4ffa-a598-e350e13385b2\r\n Abagail_Shanahan\r\n \r\n <3>\r\n 987fc185-7cc6-4883-a5f4-3273b1045155\r\n Joaquin_Funk12\r\n \r\n <4>\r\n 0c2744bb-881a-41c0-a577-603b052485d9\r\n Maverick.Corkery74\r\n \r\n ", - "latency": 0, - "statusCode": 200, - "label": "Good XML response with header", - "headers": [ - { - "key": "content-type", - "value": "application/xml" - } - ], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null, - "streamingMode": null, - "streamingInterval": 0 - }, - { - "uuid": "06b007c4-c6e4-4c47-a3b2-a72e7ae07032", - "type": "http", - "documentation": "", - "method": "get", - "endpoint": "mismatch/content-type/xml-json", - "responses": [ - { - "uuid": "74605101-a629-4d3b-96ae-888a6337e120", - "body": "[{\"id\":\"57ab8bfa-dcad-4649-9442-3b13010e852d\",\"username\":\"Damaris76\"},{\"id\":\"e88067f0-b028-4310-8a33-8cbc03203249\",\"username\":\"Modesto_Reichel61\"},{\"id\":\"fee23d87-721f-4ffa-a598-e350e13385b2\",\"username\":\"Abagail_Shanahan\"},{\"id\":\"987fc185-7cc6-4883-a5f4-3273b1045155\",\"username\":\"Joaquin_Funk12\"},{\"id\":\"0c2744bb-881a-41c0-a577-603b052485d9\",\"username\":\"Maverick.Corkery74\"}]", - "latency": 0, - "statusCode": 200, - "label": "Content-Type header says XML but content is JSON", - "headers": [ - { - "key": "Content-Type", - "value": "application/xml" - } - ], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null, - "streamingMode": null, - "streamingInterval": 0 - }, - { - "uuid": "d8566bf7-a2bd-4fa4-992f-a3be88fd03e6", - "type": "http", - "documentation": "", - "method": "get", - "endpoint": "status", - "responses": [ - { - "uuid": "894be58f-4ff1-45a5-b98b-0bbd852ac62b", - "body": "# Mock API From Hell\n\nAn API designed to test and evaluate REST Clients.\n\n- Do the REST Clients help spot the errors?\n- Are error messages readable and helpful?\n- Do the REST Clients flag errors automatically?\n\nAPI Should have a Swagger file to make it easy to import into clients.\n\nUser should be able to execute requests quickly and spot obvious issues.\n\n## Errors covered\n\n- No Error\n - [x] good endpoints to cover main content types to check REST Client can render and handle them\n - TODO: expand to cover more formats e.g. xml without header, text, html, etc.\n- API Returns malformed JSON\n - [x] Missing final collection terminator\n - TODO: expand to include more Malformed JSON conditions\n- API Returns malformed XML\n - [x] Missing final root element closure\n - TODO: expand to include more Malformed XML conditions\n- API Returns mismatched content-type e.g. content-type xml, but is json\n - [x] json-xml\n - [x] xml-json\n- Status code semantic mismatches\n - [] 201 with no location header\n - [] 3xx with no redirect location\n - [] 204 with content\n - TODO: think through and expand\n", - "latency": 0, - "statusCode": 200, - "label": "Curent Status and documentation of the API, used for todo tracking", - "headers": [ - { - "key": "Content-Type", - "value": "text/markdown" - } - ], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null, - "streamingMode": null, - "streamingInterval": 0 - } - ], - "rootChildren": [ - { - "type": "route", - "uuid": "d8566bf7-a2bd-4fa4-992f-a3be88fd03e6" - }, - { - "type": "route", - "uuid": "35cfdf52-f6b4-4ea1-8052-09e1a5247adc" - }, - { - "type": "folder", - "uuid": "f6c11405-8bc5-455e-b52e-38afc6a4cca2" - }, - { - "type": "folder", - "uuid": "ee634c9b-a1a6-493b-8034-a3639595d3cc" - }, - { - "type": "folder", - "uuid": "d65eddce-d969-4c6c-96f4-c4bf3dcecba0" - }, - { - "type": "folder", - "uuid": "469e6d4a-b5cf-4afe-b1e0-10fa423786b4" - } - ], - "proxyMode": false, - "proxyHost": "", - "proxyRemovePrefix": false, - "tlsOptions": { - "enabled": false, - "type": "CERT", - "pfxPath": "", - "certPath": "", - "keyPath": "", - "caPath": "", - "passphrase": "" - }, - "cors": true, - "headers": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Access-Control-Allow-Origin", - "value": "*" - }, - { - "key": "Access-Control-Allow-Methods", - "value": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS" - }, - { - "key": "Access-Control-Allow-Headers", - "value": "Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With" - } - ], - "proxyReqHeaders": [ - { - "key": "", - "value": "" - } - ], - "proxyResHeaders": [ - { - "key": "", - "value": "" - } - ], - "data": [], - "callbacks": [] -} \ No newline at end of file diff --git a/docs/mockapis/mockoon/fromhell/openapi-fromhell-mocksoon.json b/docs/mockapis/mockoon/fromhell/openapi-fromhell-mocksoon.json deleted file mode 100644 index 56f70725..00000000 --- a/docs/mockapis/mockoon/fromhell/openapi-fromhell-mocksoon.json +++ /dev/null @@ -1 +0,0 @@ -{"openapi":"3.0.0","info":{"title":"Fromhell","version":"1.0.0"},"servers":[{"url":"http://localhost:3001/"}],"paths":{"/status":{"get":{"description":"","responses":{"200":{"description":"Curent Status and documentation of the API, used for todo tracking","content":{"text/markdown":{"example":{}}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string"},"example":"*"},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},"Access-Control-Allow-Headers":{"schema":{"type":"string"},"example":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}}}}}},"/version":{"get":{"description":"","responses":{"200":{"description":"","content":{"application/json":{"example":"{\"version\":\"6\"}"}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string"},"example":"*"},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},"Access-Control-Allow-Headers":{"schema":{"type":"string"},"example":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}}}}}},"/good/json":{"get":{"description":"","responses":{"200":{"description":"Good JSON Response","content":{"application/json":{"example":"[{\"id\":\"57ab8bfa-dcad-4649-9442-3b13010e852d\",\"username\":\"Damaris76\"},{\"id\":\"e88067f0-b028-4310-8a33-8cbc03203249\",\"username\":\"Modesto_Reichel61\"},{\"id\":\"fee23d87-721f-4ffa-a598-e350e13385b2\",\"username\":\"Abagail_Shanahan\"},{\"id\":\"987fc185-7cc6-4883-a5f4-3273b1045155\",\"username\":\"Joaquin_Funk12\"},{\"id\":\"0c2744bb-881a-41c0-a577-603b052485d9\",\"username\":\"Maverick.Corkery74\"}]"}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string"},"example":"*"},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},"Access-Control-Allow-Headers":{"schema":{"type":"string"},"example":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}}}}}},"/good/xml":{"get":{"description":"","responses":{"200":{"description":"Good XML response with header","content":{"application/xml":{"example":{}}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string"},"example":"*"},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},"Access-Control-Allow-Headers":{"schema":{"type":"string"},"example":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}}}}}},"/malformed/json":{"get":{"description":"","responses":{"200":{"description":"Json response with missing collection termination","content":{"application/json":{"example":{}}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string"},"example":"*"},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},"Access-Control-Allow-Headers":{"schema":{"type":"string"},"example":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}}}}}},"/malformed/xml":{"get":{"description":"","responses":{"200":{"description":"XML response with missing root terminator","content":{"application/xml":{"example":{}}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string"},"example":"*"},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},"Access-Control-Allow-Headers":{"schema":{"type":"string"},"example":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}}}}}},"/mismatch/content-type/json-xml":{"get":{"description":"","responses":{"200":{"description":"Content Type states JSON but is actually XML","content":{"application/json":{"example":{}}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string"},"example":"*"},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},"Access-Control-Allow-Headers":{"schema":{"type":"string"},"example":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}}}}}},"/mismatch/content-type/xml-json":{"get":{"description":"","responses":{"200":{"description":"Content-Type header says XML but content is JSON","content":{"application/xml":{"example":"[{\"id\":\"57ab8bfa-dcad-4649-9442-3b13010e852d\",\"username\":\"Damaris76\"},{\"id\":\"e88067f0-b028-4310-8a33-8cbc03203249\",\"username\":\"Modesto_Reichel61\"},{\"id\":\"fee23d87-721f-4ffa-a598-e350e13385b2\",\"username\":\"Abagail_Shanahan\"},{\"id\":\"987fc185-7cc6-4883-a5f4-3273b1045155\",\"username\":\"Joaquin_Funk12\"},{\"id\":\"0c2744bb-881a-41c0-a577-603b052485d9\",\"username\":\"Maverick.Corkery74\"}]"}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string"},"example":"*"},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},"Access-Control-Allow-Headers":{"schema":{"type":"string"},"example":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}}}}}}}} \ No newline at end of file diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServer.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServer.java index 238221e3..8d99bd40 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServer.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServer.java @@ -59,6 +59,7 @@ public void start() { for (HttpAfterHandler afterHandler : registry.afterHandlers()) { config.routes.after(ctx -> runAfter(ctx, afterHandler)); } + config.routes.after(this::removeContentTypeFromEmptyNotFound); config.routes.after(this::restoreExactContentType); config.routes.exception( HaltRequestException.class, @@ -215,6 +216,30 @@ private void restoreExactContentType(final Context ctx) { } } + private void removeContentTypeFromEmptyNotFound(final Context ctx) { + if (ctx.statusCode() != 404) { + return; + } + + if (!hasEmptyBody(ctx)) { + return; + } + + ctx.res().setCharacterEncoding(null); + ctx.res().setContentType(null); + ctx.res().setHeader("Content-Type", null); + } + + private boolean hasEmptyBody(final Context ctx) { + String body = ctx.attribute(JavalinServerResponse.RESPONSE_BODY_ATTRIBUTE); + if (body != null) { + return body.isEmpty(); + } + + String result = ctx.result(); + return result == null || result.isEmpty(); + } + private HandlerType handlerType(final HttpRouteVerb verb) { switch (verb) { case GET: diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java index 9c7bb28f..ba37a73c 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java @@ -1,7 +1,14 @@ package uk.co.compendiumdev.thingifier.adapter.javalin; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.adapter.httpserver.HttpRouteRegistry; +import uk.co.compendiumdev.thingifier.adapter.httpserver.HttpRouteVerb; class JavalinHttpServerTest { @@ -17,4 +24,66 @@ void keepsSingleLegacyPathParamNamesReadable() { Assertions.assertEquals( "/challenger/{id}", JavalinHttpServer.javalinPath("/challenger/:id")); } + + @Test + void emptyNotFoundDoesNotReturnContentTypeHeader() throws Exception { + int port = availablePort(); + HttpRouteRegistry registry = new HttpRouteRegistry(); + registry.add( + HttpRouteVerb.GET, + "/empty-not-found", + (request, response) -> { + response.type("application/json"); + response.status(404); + return ""; + }); + + try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { + server.start(); + + HttpResponse response = get("http://localhost:" + port + "/empty-not-found"); + + Assertions.assertEquals(404, response.statusCode()); + Assertions.assertEquals("", response.body()); + Assertions.assertTrue(response.headers().firstValue("Content-Type").isEmpty()); + } + } + + @Test + void notFoundWithBodyKeepsContentTypeHeader() throws Exception { + int port = availablePort(); + HttpRouteRegistry registry = new HttpRouteRegistry(); + registry.add( + HttpRouteVerb.GET, + "/body-not-found", + (request, response) -> { + response.type("application/json"); + response.status(404); + return "{\"error\":\"missing\"}"; + }); + + try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { + server.start(); + + HttpResponse response = get("http://localhost:" + port + "/body-not-found"); + + Assertions.assertEquals(404, response.statusCode()); + Assertions.assertEquals("{\"error\":\"missing\"}", response.body()); + Assertions.assertEquals( + "application/json", response.headers().firstValue("Content-Type").orElse("")); + } + } + + private HttpResponse get(final String url) throws Exception { + return HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(new URI(url)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + } + + private int availablePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } }