├── .editorconfig ├── .github └── workflows │ ├── checks.yml │ ├── labels.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── eslint.config.js ├── index.ts ├── logo.png ├── package.json ├── src ├── client.ts ├── request.ts ├── response.ts ├── types.ts └── utils.ts ├── tests ├── api_client │ └── base.spec.ts ├── request │ ├── auth.spec.ts │ ├── base.spec.ts │ ├── body.spec.ts │ ├── cookies.spec.ts │ ├── custom_types.spec.ts │ ├── headers.spec.ts │ ├── lifecycle_hooks.spec.ts │ └── query_string.spec.ts └── response │ ├── assertions.spec.ts │ ├── base.spec.ts │ ├── cookies.spec.ts │ ├── custom_types.spec.ts │ ├── data_types.spec.ts │ ├── error_handling.spec.ts │ ├── lifecycle_hooks.spec.ts │ └── redirects.spec.ts ├── tests_helpers └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: japa/.github/.github/workflows/test.yml@main 10 | 11 | lint: 12 | uses: japa/.github/.github/workflows/lint.yml@main 13 | 14 | typecheck: 15 | uses: japa/.github/.github/workflows/typecheck.yml@main 16 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | - name: git config 20 | run: | 21 | git config user.name "${GITHUB_ACTOR}" 22 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 23 | - name: Init npm config 24 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | - run: npm install 28 | - run: npm run release -- --ci 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 0 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still need help on this issue' 13 | stale-pr-message: 'This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request' 14 | close-issue-message: 'This issue has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still need help on this issue' 15 | close-pr-message: 'This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request' 16 | days-before-stale: 21 17 | days-before-close: 5 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | test/__app 4 | .DS_STORE 5 | .nyc_output 6 | .idea 7 | .vscode/ 8 | *.sublime-project 9 | *.sublime-workspace 10 | *.log 11 | build 12 | dist 13 | shrinkwrap.yaml 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.md 4 | config.json 5 | .eslintrc.json 6 | package.json 7 | *.html 8 | *.txt 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2022 Harminder Virk, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @japa/client 2 | > API client to test endpoints over HTTP. Uses superagent under the hood 3 | 4 | [![github-actions-image]][github-actions-url] [![npm-image]][npm-url] [![license-image]][license-url] [![typescript-image]][typescript-url] 5 | 6 | The API client plugin of Japa makes it super simple to test your API endpoints over HTTP. You can use it to test any HTTP endpoint that returns JSON, XML, HTML, or even plain text. 7 | 8 | It has out of the box support for: 9 | 10 | - Multiple content types including `application/json`, `application/x-www-form-urlencoded` and `multipart`. 11 | - Ability to upload files. 12 | - Read and write cookies with the option to register custom cookies serializer. 13 | - Lifecycle hooks. A great use-case of hooks is to persist and load session data during a request. 14 | - All other common abilities like sending headers, query-string, and following redirects. 15 | - Support for registering custom body serializers and parsers. 16 | 17 | #### [Complete API documentation](https://japa.dev/docs/plugins/api-client) 18 | 19 | ## Installation 20 | Install the package from the npm registry as follows: 21 | 22 | ```sh 23 | npm i @japa/api-client 24 | 25 | yarn add @japa/api-client 26 | ``` 27 | 28 | ## Usage 29 | You can use the assertion package with the `@japa/runner` as follows. 30 | 31 | ```ts 32 | import { apiClient } from '@japa/api-client' 33 | import { configure } from '@japa/runner' 34 | 35 | configure({ 36 | plugins: [apiClient({ baseURL: 'http://localhost:3333' })] 37 | }) 38 | ``` 39 | 40 | Once done, you will be able to access the `client` property from the test context. 41 | 42 | ```ts 43 | test('test title', ({ client }) => { 44 | const response = await client.get('/') 45 | }) 46 | ``` 47 | 48 | [github-actions-url]: https://github.com/japa/api-client/actions/workflows/checks.yml 49 | [github-actions-image]: https://img.shields.io/github/actions/workflow/status/japa/api-client/checks.yml?style=for-the-badge "github-actions" 50 | 51 | [npm-image]: https://img.shields.io/npm/v/@japa/api-client.svg?style=for-the-badge&logo=npm 52 | [npm-url]: https://npmjs.org/package/@japa/api-client "npm" 53 | 54 | [license-image]: https://img.shields.io/npm/l/@japa/api-client?color=blueviolet&style=for-the-badge 55 | [license-url]: LICENSE.md "license" 56 | 57 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 58 | [typescript-url]: "typescript" 59 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { configure, processCLIArgs, run } from '@japa/runner' 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Configure tests 7 | |-------------------------------------------------------------------------- 8 | | 9 | | The configure method accepts the configuration to configure the Japa 10 | | tests runner. 11 | | 12 | | The first method call "processCliArgs" process the command line arguments 13 | | and turns them into a config object. Using this method is not mandatory. 14 | | 15 | | Please consult japa.dev/runner-config for the config docs. 16 | */ 17 | processCLIArgs(process.argv.slice(2)) 18 | configure({ 19 | files: ['tests/**/*.spec.ts'], 20 | plugins: [assert()], 21 | }) 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Run tests 26 | |-------------------------------------------------------------------------- 27 | | 28 | | The following "run" method is required to execute all the tests. 29 | | 30 | */ 31 | run() 32 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg({ 3 | ignores: ['coverage'], 4 | }) 5 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { PluginFn } from '@japa/runner/types' 11 | import { ApiClient } from './src/client.js' 12 | import { TestContext } from '@japa/runner/core' 13 | 14 | export { ApiClient } 15 | export { ApiRequest } from './src/request.js' 16 | export { ApiResponse } from './src/response.js' 17 | 18 | /** 19 | * API client plugin registers an HTTP request client that 20 | * can be used for testing API endpoints. 21 | */ 22 | export function apiClient(options?: string | { baseURL?: string }): PluginFn { 23 | return function () { 24 | TestContext.getter( 25 | 'client', 26 | function (this: TestContext) { 27 | return new ApiClient(typeof options === 'string' ? options : options?.baseURL, this.assert) 28 | }, 29 | true 30 | ) 31 | } 32 | } 33 | 34 | declare module '@japa/runner/core' { 35 | interface TestContext { 36 | client: ApiClient 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/japa/api-client/f28d4d8fb72f97a8435a29561237c6010b0b47ed/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@japa/api-client", 3 | "description": "Browser and API testing client for Japa. Built on top of Playwright", 4 | "version": "3.1.0", 5 | "engines": { 6 | "node": ">=18.16.0" 7 | }, 8 | "main": "./build/index.js", 9 | "type": "module", 10 | "files": [ 11 | "build", 12 | "!build/bin", 13 | "!build/tests_helpers", 14 | "!build/tests" 15 | ], 16 | "exports": { 17 | ".": "./build/index.js", 18 | "./types": "./build/src/types.js" 19 | }, 20 | "scripts": { 21 | "pretest": "npm run lint", 22 | "test": "c8 npm run quick:test", 23 | "lint": "eslint .", 24 | "format": "prettier --write .", 25 | "typecheck": "tsc --noEmit", 26 | "clean": "del-cli build", 27 | "precompile": "npm run lint && npm run clean", 28 | "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", 29 | "build": "npm run compile", 30 | "version": "npm run build", 31 | "prepublishOnly": "npm run build", 32 | "release": "release-it", 33 | "quick:test": "node --import=ts-node-maintained/register/esm --enable-source-maps bin/test.ts" 34 | }, 35 | "devDependencies": { 36 | "@adonisjs/eslint-config": "^2.0.0", 37 | "@adonisjs/prettier-config": "^1.4.4", 38 | "@adonisjs/tsconfig": "^1.4.0", 39 | "@japa/assert": "^4.0.1", 40 | "@japa/openapi-assertions": "^0.1.1", 41 | "@japa/runner": "^4.2.0", 42 | "@release-it/conventional-changelog": "^10.0.1", 43 | "@swc/core": "1.10.7", 44 | "@types/node": "^22.15.12", 45 | "@types/qs": "^6.9.18", 46 | "@types/set-cookie-parser": "^2.4.10", 47 | "c8": "^10.1.3", 48 | "cheerio": "^1.0.0", 49 | "del-cli": "^6.0.0", 50 | "eslint": "^9.26.0", 51 | "prettier": "^3.5.3", 52 | "qs": "^6.14.0", 53 | "release-it": "^19.0.2", 54 | "ts-node-maintained": "^10.9.5", 55 | "tsup": "^8.4.0", 56 | "typescript": "^5.8.3" 57 | }, 58 | "dependencies": { 59 | "@poppinss/hooks": "^7.2.5", 60 | "@poppinss/macroable": "^1.0.4", 61 | "@types/superagent": "^8.1.9", 62 | "cookie": "^1.0.2", 63 | "set-cookie-parser": "^2.7.1", 64 | "superagent": "^10.2.1" 65 | }, 66 | "peerDependencies": { 67 | "@japa/assert": "^2.0.0 || ^3.0.0 || ^4.0.0", 68 | "@japa/openapi-assertions": "^0.1.1", 69 | "@japa/runner": "^3.1.2 || ^4.0.0" 70 | }, 71 | "peerDependenciesMeta": { 72 | "@japa/assert": { 73 | "optional": true 74 | }, 75 | "@japa/openapi-assertions": { 76 | "optional": true 77 | } 78 | }, 79 | "homepage": "https://github.com/japa/api-client#readme", 80 | "repository": { 81 | "type": "git", 82 | "url": "git+https://github.com/japa/api-client.git" 83 | }, 84 | "bugs": { 85 | "url": "https://github.com/japa/api-client/issues" 86 | }, 87 | "keywords": [ 88 | "playwright", 89 | "browser-tests", 90 | "tests", 91 | "e2e", 92 | "api-tests" 93 | ], 94 | "author": "Harminder Virk ", 95 | "license": "MIT", 96 | "publishConfig": { 97 | "access": "public", 98 | "provenance": true 99 | }, 100 | "tsup": { 101 | "entry": [ 102 | "./index.ts", 103 | "./src/types.ts" 104 | ], 105 | "outDir": "./build", 106 | "clean": true, 107 | "format": "esm", 108 | "dts": false, 109 | "sourcemap": false, 110 | "target": "esnext" 111 | }, 112 | "release-it": { 113 | "git": { 114 | "requireCleanWorkingDir": true, 115 | "requireUpstream": true, 116 | "commitMessage": "chore(release): ${version}", 117 | "tagAnnotation": "v${version}", 118 | "push": true, 119 | "tagName": "v${version}" 120 | }, 121 | "github": { 122 | "release": true 123 | }, 124 | "npm": { 125 | "publish": true, 126 | "skipChecks": true 127 | }, 128 | "plugins": { 129 | "@release-it/conventional-changelog": { 130 | "preset": { 131 | "name": "angular" 132 | } 133 | } 134 | } 135 | }, 136 | "c8": { 137 | "reporter": [ 138 | "text", 139 | "html" 140 | ], 141 | "exclude": [ 142 | "tests/**" 143 | ] 144 | }, 145 | "prettier": "@adonisjs/prettier-config" 146 | } 147 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import Macroable from '@poppinss/macroable' 11 | import type { Assert } from '@japa/assert' 12 | 13 | import { ApiRequest } from './request.js' 14 | import { SetupHandler, TeardownHandler, CookiesSerializer } from './types.js' 15 | 16 | /** 17 | * ApiClient exposes the API to make HTTP requests in context of 18 | * testing. 19 | */ 20 | export class ApiClient extends Macroable { 21 | /** 22 | * Invoked when a new instance of request is created 23 | */ 24 | static #onRequestHandlers: ((request: ApiRequest) => void)[] = [] 25 | 26 | /** 27 | * Hooks handlers to pass onto the request 28 | */ 29 | static #hooksHandlers: { 30 | setup: SetupHandler[] 31 | teardown: TeardownHandler[] 32 | } = { 33 | setup: [], 34 | teardown: [], 35 | } 36 | 37 | static #customCookiesSerializer?: CookiesSerializer 38 | 39 | #baseUrl?: string 40 | #assert?: Assert 41 | 42 | constructor(baseUrl?: string, assert?: Assert) { 43 | super() 44 | 45 | this.#baseUrl = baseUrl 46 | this.#assert = assert 47 | } 48 | 49 | /** 50 | * Remove all globally registered setup hooks 51 | */ 52 | static clearSetupHooks() { 53 | this.#hooksHandlers.setup = [] 54 | return this 55 | } 56 | 57 | /** 58 | * Remove all globally registered teardown hooks 59 | */ 60 | static clearTeardownHooks() { 61 | this.#hooksHandlers.teardown = [] 62 | return this 63 | } 64 | 65 | /** 66 | * Clear on request handlers registered using "onRequest" 67 | * method 68 | */ 69 | static clearRequestHandlers() { 70 | this.#onRequestHandlers = [] 71 | return this 72 | } 73 | 74 | /** 75 | * Register a handler to be invoked everytime a new request 76 | * instance is created 77 | */ 78 | static onRequest(handler: (request: ApiRequest) => void) { 79 | this.#onRequestHandlers.push(handler) 80 | return this 81 | } 82 | 83 | /** 84 | * Register setup hooks. Setup hooks are called before the request 85 | */ 86 | static setup(handler: SetupHandler) { 87 | this.#hooksHandlers.setup.push(handler) 88 | return this 89 | } 90 | 91 | /** 92 | * Register teardown hooks. Teardown hooks are called before the request 93 | */ 94 | static teardown(handler: TeardownHandler) { 95 | this.#hooksHandlers.teardown.push(handler) 96 | return this 97 | } 98 | 99 | /** 100 | * Register a custom cookies serializer 101 | */ 102 | static cookiesSerializer(serailizer: CookiesSerializer) { 103 | this.#customCookiesSerializer = serailizer 104 | return this 105 | } 106 | 107 | /** 108 | * Create an instance of the request 109 | */ 110 | request(endpoint: string, method: string) { 111 | const hooks = (this.constructor as typeof ApiClient).#hooksHandlers 112 | const requestHandlers = (this.constructor as typeof ApiClient).#onRequestHandlers 113 | const cookiesSerializer = (this.constructor as typeof ApiClient).#customCookiesSerializer 114 | 115 | let baseUrl = this.#baseUrl 116 | const envHost = process.env.HOST 117 | const envPort = process.env.PORT 118 | 119 | /** 120 | * Compute baseUrl from the HOST and the PORT env variables 121 | * when no baseUrl is provided 122 | */ 123 | if (!baseUrl && envHost && envPort) { 124 | baseUrl = `http://${envHost}:${envPort}` 125 | } 126 | 127 | const request = new ApiRequest( 128 | { 129 | baseUrl, 130 | method, 131 | endpoint, 132 | hooks, 133 | serializers: { cookie: cookiesSerializer }, 134 | }, 135 | this.#assert 136 | ) 137 | 138 | requestHandlers.forEach((handler) => handler(request)) 139 | return request 140 | } 141 | 142 | /** 143 | * Create an instance of the request for GET method 144 | */ 145 | get(endpoint: string) { 146 | return this.request(endpoint, 'GET') 147 | } 148 | 149 | /** 150 | * Create an instance of the request for POST method 151 | */ 152 | post(endpoint: string) { 153 | return this.request(endpoint, 'POST') 154 | } 155 | 156 | /** 157 | * Create an instance of the request for PUT method 158 | */ 159 | put(endpoint: string) { 160 | return this.request(endpoint, 'PUT') 161 | } 162 | 163 | /** 164 | * Create an instance of the request for PATCH method 165 | */ 166 | patch(endpoint: string) { 167 | return this.request(endpoint, 'PATCH') 168 | } 169 | 170 | /** 171 | * Create an instance of the request for DELETE method 172 | */ 173 | delete(endpoint: string) { 174 | return this.request(endpoint, 'DELETE') 175 | } 176 | 177 | /** 178 | * Create an instance of the request for HEAD method 179 | */ 180 | head(endpoint: string) { 181 | return this.request(endpoint, 'HEAD') 182 | } 183 | 184 | /** 185 | * Create an instance of the request for OPTIONS method 186 | */ 187 | options(endpoint: string) { 188 | return this.request(endpoint, 'OPTIONS') 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import cookie from 'cookie' 11 | import Hooks from '@poppinss/hooks' 12 | import type { Assert } from '@japa/assert' 13 | import Macroable from '@poppinss/macroable' 14 | import superagent, { Response, SuperAgentRequest } from 'superagent' 15 | 16 | import { ApiResponse } from './response.js' 17 | import { dumpRequest, dumpRequestBody, dumpRequestCookies, dumpRequestHeaders } from './utils.js' 18 | import type { 19 | SetupHandler, 20 | RequestConfig, 21 | MultipartValue, 22 | RequestCookies, 23 | TeardownHandler, 24 | SuperAgentParser, 25 | SuperAgentSerializer, 26 | ApiRequestHooks, 27 | } from './types.js' 28 | 29 | const DUMP_CALLS = { 30 | request: dumpRequest, 31 | body: dumpRequestBody, 32 | cookies: dumpRequestCookies, 33 | headers: dumpRequestHeaders, 34 | } 35 | 36 | export class ApiRequest extends Macroable { 37 | /** 38 | * The serializer to use for serializing request query params 39 | */ 40 | static qsSerializer: SuperAgentSerializer = (value) => value 41 | 42 | /** 43 | * Register/remove custom superagent parser, Parsers are used 44 | * to parse the incoming response 45 | */ 46 | static addParser = (contentType: string, parser: SuperAgentParser) => { 47 | superagent.parse[contentType] = parser 48 | } 49 | static removeParser = (contentType: string) => { 50 | delete superagent.parse[contentType] 51 | } 52 | 53 | /** 54 | * Register/remove custom superagent serializers. Serializers are used 55 | * to serialize the request body 56 | */ 57 | static addSerializer = (contentType: string, serializer: SuperAgentSerializer) => { 58 | superagent.serialize[contentType] = serializer 59 | } 60 | static removeSerializer = (contentType: string) => { 61 | delete superagent.serialize[contentType] 62 | } 63 | 64 | /** 65 | * Specify the serializer for query strings. Serializers are used to convert 66 | * request querystring values to a string 67 | */ 68 | static setQsSerializer = (serializer: SuperAgentSerializer) => { 69 | ApiRequest.qsSerializer = serializer 70 | } 71 | static removeQsSerializer = () => { 72 | ApiRequest.qsSerializer = (value) => value 73 | } 74 | 75 | /** 76 | * Reference to registered hooks 77 | */ 78 | hooks = new Hooks() 79 | #setupRunner!: ReturnType['runner']> 80 | #teardownRunner!: ReturnType['runner']> 81 | 82 | /** 83 | * Reference to Assert module 84 | */ 85 | #assert?: Assert 86 | 87 | /** 88 | * Dump calls 89 | */ 90 | #valuesToDump: Set<'cookies' | 'body' | 'headers' | 'request'> = new Set() 91 | 92 | /** 93 | * The underlying super agent request 94 | */ 95 | request: SuperAgentRequest 96 | 97 | /** 98 | * Cookies to be sent with the request 99 | */ 100 | cookiesJar: RequestCookies = {} 101 | 102 | constructor( 103 | public config: RequestConfig, 104 | assert?: Assert 105 | ) { 106 | super() 107 | this.#assert = assert 108 | this.request = this.#createRequest() 109 | this.config.hooks?.setup.forEach((handler) => this.setup(handler)) 110 | this.config.hooks?.teardown.forEach((handler) => this.teardown(handler)) 111 | } 112 | 113 | /** 114 | * Set cookies header 115 | */ 116 | #setCookiesHeader() { 117 | const prepareMethod = this.config.serializers?.cookie?.prepare 118 | 119 | const cookies = Object.keys(this.cookiesJar).map((key) => { 120 | let { name, value } = this.cookiesJar[key] 121 | if (prepareMethod) { 122 | value = prepareMethod(name, value, this) 123 | } 124 | return cookie.serialize(name, value) 125 | }) 126 | 127 | if (!cookies.length) { 128 | return 129 | } 130 | 131 | this.header('Cookie', cookies) 132 | } 133 | 134 | /** 135 | * Instantiate hooks runner 136 | */ 137 | #instantiateHooksRunners() { 138 | this.#setupRunner = this.hooks.runner('setup') 139 | this.#teardownRunner = this.hooks.runner('teardown') 140 | } 141 | 142 | /** 143 | * Run setup hooks 144 | */ 145 | async #runSetupHooks() { 146 | try { 147 | await this.#setupRunner.run(this) 148 | } catch (error) { 149 | await this.#setupRunner.cleanup(error, this) 150 | throw error 151 | } 152 | } 153 | 154 | /** 155 | * Run teardown hooks 156 | */ 157 | async #runTeardownHooks(response: ApiResponse) { 158 | try { 159 | await this.#teardownRunner.run(response) 160 | } catch (error) { 161 | await this.#teardownRunner.cleanup(error, response) 162 | throw error 163 | } 164 | 165 | await this.#teardownRunner.cleanup(null, response) 166 | } 167 | 168 | /** 169 | * Send HTTP request to the server. Errors except the client errors 170 | * are tured into a response object. 171 | */ 172 | async #sendRequest() { 173 | let response: Response 174 | 175 | try { 176 | this.#setCookiesHeader() 177 | this.#dumpValues() 178 | response = await this.request.buffer(true) 179 | } catch (error) { 180 | this.request.abort() 181 | 182 | /** 183 | * Call cleanup hooks 184 | */ 185 | if (!error.response) { 186 | await this.#setupRunner.cleanup(error, this) 187 | throw error 188 | } 189 | 190 | /** 191 | * For all HTTP errors (including 500+), return the error response 192 | * This allows proper handling of server errors via ApiResponse 193 | */ 194 | response = error.response 195 | } 196 | 197 | await this.#setupRunner.cleanup(null, this) 198 | return new ApiResponse(this, response, this.config, this.#assert) 199 | } 200 | 201 | /** 202 | * Invoke calls calls 203 | */ 204 | #dumpValues() { 205 | if (!this.#valuesToDump.size) { 206 | return 207 | } 208 | 209 | try { 210 | this.#valuesToDump.forEach((key) => { 211 | DUMP_CALLS[key](this) 212 | }) 213 | } catch (error) { 214 | console.log(error) 215 | } 216 | } 217 | 218 | /** 219 | * Is endpoint a fully qualified URL or not 220 | */ 221 | #isUrl(url: string) { 222 | return url.startsWith('http://') || url.startsWith('https://') 223 | } 224 | 225 | /** 226 | * Prepend baseUrl to the endpoint 227 | */ 228 | #prependBaseUrl(url: string) { 229 | if (!this.config.baseUrl) { 230 | return url 231 | } 232 | 233 | return `${this.config.baseUrl}/${url.replace(/^\//, '')}` 234 | } 235 | 236 | /** 237 | * Creates the request instance for the given HTTP method 238 | */ 239 | #createRequest() { 240 | let url = this.config.endpoint 241 | if (!this.#isUrl(url)) { 242 | url = this.#prependBaseUrl(url) 243 | } 244 | 245 | return superagent(this.config.method, url) 246 | } 247 | 248 | /** 249 | * Register a setup hook. Setup hooks are called before 250 | * making the request 251 | */ 252 | setup(handler: SetupHandler): this { 253 | this.hooks.add('setup', handler) 254 | return this 255 | } 256 | 257 | /** 258 | * Register a teardown hook. Teardown hooks are called after 259 | * making the request 260 | */ 261 | teardown(handler: TeardownHandler): this { 262 | this.hooks.add('teardown', handler) 263 | return this 264 | } 265 | 266 | /** 267 | * Set cookie as a key-value pair to be sent to the server 268 | */ 269 | cookie(key: string, value: any): this { 270 | this.cookiesJar[key] = { name: key, value } 271 | return this 272 | } 273 | 274 | /** 275 | * Set cookies as an object to be sent to the server 276 | */ 277 | cookies(cookies: Record): this { 278 | Object.keys(cookies).forEach((key) => this.cookie(key, cookies[key])) 279 | return this 280 | } 281 | 282 | /** 283 | * Define request header as a key-value pair. 284 | * 285 | * @example 286 | * request.header('x-foo', 'bar') 287 | * request.header('x-foo', ['bar', 'baz']) 288 | */ 289 | header(key: string, value: string | string[]) { 290 | this.headers({ [key]: value }) 291 | return this 292 | } 293 | 294 | /** 295 | * Define request headers as an object. 296 | * 297 | * @example 298 | * request.headers({ 'x-foo': 'bar' }) 299 | * request.headers({ 'x-foo': ['bar', 'baz'] }) 300 | */ 301 | headers(headers: Record) { 302 | this.request.set(headers) 303 | return this 304 | } 305 | 306 | /** 307 | * Define the field value for a multipart request. 308 | * 309 | * @note: This method makes a multipart request. See [[this.form]] to 310 | * make HTML style form submissions. 311 | * 312 | * @example 313 | * request.field('name', 'virk') 314 | * request.field('age', 22) 315 | */ 316 | field(name: string, value: MultipartValue | MultipartValue[]) { 317 | this.request.field(name, value) 318 | return this 319 | } 320 | 321 | /** 322 | * Define fields as an object for a multipart request 323 | * 324 | * @note: This method makes a multipart request. See [[this.form]] to 325 | * make HTML style form submissions. 326 | * 327 | * @example 328 | * request.fields({'name': 'virk', age: 22}) 329 | */ 330 | fields(values: { [name: string]: MultipartValue | MultipartValue[] }) { 331 | this.request.field(values) 332 | return this 333 | } 334 | 335 | /** 336 | * Upload file for a multipart request. Either you can pass path to a 337 | * file, a readable stream, or a buffer 338 | * 339 | * @example 340 | * request.file('avatar', 'absolute/path/to/file') 341 | * request.file('avatar', createReadStream('./path/to/file')) 342 | */ 343 | file( 344 | name: string, 345 | value: MultipartValue, 346 | options?: string | { filename?: string | undefined; contentType?: string | undefined } 347 | ) { 348 | this.request.attach(name, value, options) 349 | return this 350 | } 351 | 352 | /** 353 | * Set form values. Calling this method will set the content type 354 | * to "application/x-www-form-urlencoded". 355 | * 356 | * @example 357 | * request.form({ 358 | * email: 'virk@adonisjs.com', 359 | * password: 'secret' 360 | * }) 361 | */ 362 | form(values: string | object) { 363 | this.type('form') 364 | this.request.send(values) 365 | return this 366 | } 367 | 368 | /** 369 | * Set JSON body for the request. Calling this method will set 370 | * the content type to "application/json". 371 | * 372 | * @example 373 | * request.json({ 374 | * email: 'virk@adonisjs.com', 375 | * password: 'secret' 376 | * }) 377 | */ 378 | json(values: string | object) { 379 | this.type('json') 380 | this.request.send(values) 381 | return this 382 | } 383 | 384 | /** 385 | * Set querystring for the request. 386 | * 387 | * @example 388 | * request.qs('order_by', 'id') 389 | * request.qs({ order_by: 'id' }) 390 | */ 391 | qs(key: string, value: any): this 392 | qs(values: string | object): this 393 | qs(key: string | object, value?: any): this { 394 | if (!value) { 395 | this.request.query(typeof key === 'string' ? key : ApiRequest.qsSerializer(key)) 396 | } else { 397 | this.request.query(ApiRequest.qsSerializer({ [key as string]: value })) 398 | } 399 | return this 400 | } 401 | 402 | /** 403 | * Set timeout for the request. 404 | * 405 | * @example 406 | * request.timeout(5000) 407 | * request.timeout({ response: 5000, deadline: 60000 }) 408 | */ 409 | timeout(ms: number | { deadline?: number | undefined; response?: number | undefined }): this { 410 | this.request.timeout(ms) 411 | return this 412 | } 413 | 414 | /** 415 | * Set content-type for the request 416 | * 417 | * @example 418 | * request.type('json') 419 | */ 420 | type(value: string): this { 421 | this.request.type(value) 422 | return this 423 | } 424 | 425 | /** 426 | * Set "accept" header in the request 427 | * 428 | * @example 429 | * request.accept('json') 430 | */ 431 | accept(type: string): this { 432 | this.request.accept(type) 433 | return this 434 | } 435 | 436 | /** 437 | * Follow redirects from the response 438 | * 439 | * @example 440 | * request.redirects(3) 441 | */ 442 | redirects(count: number): this { 443 | this.request.redirects(count) 444 | return this 445 | } 446 | 447 | /** 448 | * Set basic auth header from user and password 449 | * 450 | * @example 451 | * request.basicAuth('foo@bar.com', 'secret') 452 | */ 453 | basicAuth(user: string, password: string): this { 454 | this.request.auth(user, password, { type: 'basic' }) 455 | return this 456 | } 457 | 458 | /** 459 | * Pass auth bearer token as authorization header. 460 | * 461 | * @example 462 | * request.apiToken('tokenValue') 463 | */ 464 | bearerToken(token: string): this { 465 | this.request.auth(token, { type: 'bearer' }) 466 | return this 467 | } 468 | 469 | /** 470 | * Set the ca certificates to trust 471 | */ 472 | ca(certificate: string | string[] | Buffer | Buffer[]): this { 473 | this.request.ca(certificate) 474 | return this 475 | } 476 | 477 | /** 478 | * Set the client certificates 479 | */ 480 | cert(certificate: string | string[] | Buffer | Buffer[]): this { 481 | this.request.cert(certificate) 482 | return this 483 | } 484 | 485 | /** 486 | * Set the client private key(s) 487 | */ 488 | privateKey(key: string | string[] | Buffer | Buffer[]): this { 489 | this.request.key(key) 490 | return this 491 | } 492 | 493 | /** 494 | * Set the client PFX or PKCS12 encoded private key and certificate chain 495 | */ 496 | pfx( 497 | key: string | string[] | Buffer | Buffer[] | { pfx: string | Buffer; passphrase: string } 498 | ): this { 499 | this.request.pfx(key) 500 | return this 501 | } 502 | 503 | /** 504 | * Does not reject expired or invalid TLS certs. Sets internally rejectUnauthorized=true 505 | */ 506 | disableTLSCerts(): this { 507 | this.request.disableTLSCerts() 508 | return this 509 | } 510 | 511 | /** 512 | * Trust broken HTTPs connections on localhost 513 | */ 514 | trustLocalhost(trust = true): this { 515 | this.request.trustLocalhost(trust) 516 | return this 517 | } 518 | 519 | /** 520 | * Dump request headers 521 | */ 522 | dumpHeaders(): this { 523 | this.#valuesToDump.add('headers') 524 | return this 525 | } 526 | 527 | /** 528 | * Dump request cookies 529 | */ 530 | dumpCookies(): this { 531 | this.#valuesToDump.add('cookies') 532 | return this 533 | } 534 | 535 | /** 536 | * Dump request body 537 | */ 538 | dumpBody(): this { 539 | this.#valuesToDump.add('body') 540 | return this 541 | } 542 | 543 | /** 544 | * Dump request 545 | */ 546 | dump(): this { 547 | this.#valuesToDump.add('request') 548 | this.dumpCookies() 549 | this.dumpHeaders() 550 | this.dumpBody() 551 | return this 552 | } 553 | 554 | /** 555 | * Retry a failing request. Along with the count, you can also define 556 | * a callback to decide how long the request should be retried. 557 | * 558 | * The max count is applied regardless of whether callback is defined 559 | * or not 560 | * 561 | * The following response codes are considered failing. 562 | * - 408 563 | * - 413 564 | * - 429 565 | * - 500 566 | * - 502 567 | * - 503 568 | * - 504 569 | * - 521 570 | * - 522 571 | * - 524 572 | * 573 | * The following error codes are considered failing. 574 | * - 'ETIMEDOUT' 575 | * - 'ECONNRESET' 576 | * - 'EADDRINUSE' 577 | * - 'ECONNREFUSED' 578 | * - 'EPIPE' 579 | * - 'ENOTFOUND' 580 | * - 'ENETUNREACH' 581 | * - 'EAI_AGAIN' 582 | */ 583 | retry(count: number, retryUntilCallback?: (error: any, response: ApiResponse) => boolean): this { 584 | if (retryUntilCallback) { 585 | this.request.retry(count, (error, response) => { 586 | return retryUntilCallback(error, new ApiResponse(this, response, this.config, this.#assert)) 587 | }) 588 | 589 | return this 590 | } 591 | 592 | this.request.retry(count) 593 | return this 594 | } 595 | 596 | /** 597 | * Make the API request 598 | */ 599 | async send() { 600 | /** 601 | * Step 1: Instantiate hooks runners 602 | */ 603 | this.#instantiateHooksRunners() 604 | 605 | /** 606 | * Step 2: Run setup hooks 607 | */ 608 | await this.#runSetupHooks() 609 | 610 | /** 611 | * Step 3: Make HTTP request 612 | */ 613 | const response = await this.#sendRequest() 614 | 615 | /** 616 | * Step 4: Run teardown hooks 617 | */ 618 | await this.#runTeardownHooks(response) 619 | 620 | return response 621 | } 622 | 623 | /** 624 | * Implementation of `then` for the promise API 625 | */ 626 | then( 627 | resolve?: ((value: ApiResponse) => TResult1 | PromiseLike) | undefined | null, 628 | reject?: ((reason: any) => TResult2 | PromiseLike) | undefined | null 629 | ): Promise { 630 | return this.send().then(resolve, reject) 631 | } 632 | 633 | /** 634 | * Implementation of `catch` for the promise API 635 | */ 636 | catch( 637 | reject?: ((reason: ApiResponse) => TResult | PromiseLike) | undefined | null 638 | ): Promise { 639 | return this.send().catch(reject) 640 | } 641 | 642 | /** 643 | * Implementation of `finally` for the promise API 644 | */ 645 | finally(fullfilled?: (() => void) | undefined | null): Promise { 646 | return this.send().finally(fullfilled) 647 | } 648 | 649 | /** 650 | * Required when Promises are extended 651 | */ 652 | get [Symbol.toStringTag]() { 653 | return this.constructor.name 654 | } 655 | } 656 | -------------------------------------------------------------------------------- /src/response.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import { Assert } from '@japa/assert' 13 | import Macroable from '@poppinss/macroable' 14 | import setCookieParser from 'set-cookie-parser' 15 | import { type HTTPError, Response } from 'superagent' 16 | 17 | import { ApiRequest } from './request.js' 18 | import { RequestConfig, ResponseCookie, ResponseCookies, SuperAgentResponseFile } from './types.js' 19 | import { 20 | dumpResponse, 21 | dumpResponseBody, 22 | dumpResponseError, 23 | dumpResponseCookies, 24 | dumpResponseHeaders, 25 | } from './utils.js' 26 | 27 | export class ApiResponse extends Macroable { 28 | #valuesDumped: Set = new Set() 29 | 30 | /** 31 | * Parsed cookies 32 | */ 33 | cookiesJar: ResponseCookies 34 | 35 | constructor( 36 | public request: ApiRequest, 37 | public response: Response, 38 | protected config: RequestConfig, 39 | public assert?: Assert 40 | ) { 41 | super() 42 | this.cookiesJar = this.#parseCookies() 43 | this.#processCookies() 44 | } 45 | 46 | /** 47 | * Parse response header to collect cookies 48 | */ 49 | #parseCookies(): ResponseCookies { 50 | const cookieHeader = this.header('set-cookie') 51 | if (!cookieHeader) { 52 | return {} 53 | } 54 | 55 | return setCookieParser.parse(cookieHeader, { map: true }) 56 | } 57 | 58 | /** 59 | * Process cookies using the serializer 60 | */ 61 | #processCookies() { 62 | const cookiesSerializer = this.config.serializers?.cookie 63 | const processMethod = cookiesSerializer?.process 64 | 65 | if (!processMethod) { 66 | return 67 | } 68 | 69 | Object.keys(this.cookiesJar).forEach((key) => { 70 | const cookie = this.cookiesJar[key] 71 | const processedValue = processMethod(cookie.name, cookie.value, this) 72 | if (processedValue !== undefined) { 73 | cookie.value = processedValue 74 | } 75 | }) 76 | } 77 | 78 | /** 79 | * Ensure assert plugin is installed and configured 80 | */ 81 | #ensureHasAssert() { 82 | if (!this.assert) { 83 | throw new Error( 84 | 'Response assertions are not available. Make sure to install the @japa/assert plugin' 85 | ) 86 | } 87 | } 88 | 89 | /** 90 | * Ensure OpenAPI assertions package is installed and 91 | * configured 92 | */ 93 | #ensureHasOpenAPIAssertions() { 94 | this.#ensureHasAssert() 95 | if ('isValidApiResponse' in this.assert! === false) { 96 | throw new Error( 97 | 'OpenAPI assertions are not available. Make sure to install the @japa/openapi-assertions plugin' 98 | ) 99 | } 100 | } 101 | 102 | /** 103 | * Response content-type charset. Undefined if no charset 104 | * is mentioned. 105 | */ 106 | charset(): string | undefined { 107 | return this.response.charset 108 | } 109 | 110 | /** 111 | * Parsed files from the multipart response. 112 | */ 113 | files(): { [K in Properties]: SuperAgentResponseFile } { 114 | return this.response.files 115 | } 116 | 117 | /** 118 | * Returns an object of links by parsing the "Link" header. 119 | * 120 | * @example 121 | * Link: ; rel="preconnect", ; rel="preload" 122 | * response.links() 123 | * // { 124 | * // preconnect: 'https://one.example.com', 125 | // preload: 'https://two.example.com', 126 | * // } 127 | */ 128 | links(): Record { 129 | return this.response.links 130 | } 131 | 132 | /** 133 | * Response status type 134 | */ 135 | statusType(): number { 136 | return this.response.statusType 137 | } 138 | 139 | /** 140 | * Request raw parsed text 141 | */ 142 | text(): string { 143 | return this.response.text 144 | } 145 | 146 | /** 147 | * Response body 148 | */ 149 | body(): any { 150 | return this.response.body 151 | } 152 | 153 | /** 154 | * Read value for a given response header 155 | */ 156 | header(key: string): string | undefined { 157 | key = key.toLowerCase() 158 | return this.response.headers[key] 159 | } 160 | 161 | /** 162 | * Get all response headers 163 | */ 164 | headers(): Record { 165 | return this.response.headers 166 | } 167 | 168 | /** 169 | * Get response status 170 | */ 171 | status(): number { 172 | return this.response.status 173 | } 174 | 175 | /** 176 | * Get response content-type 177 | */ 178 | type() { 179 | return this.response.type 180 | } 181 | 182 | /** 183 | * Get redirects URLs the request has followed before 184 | * getting the response 185 | */ 186 | redirects() { 187 | return this.response.redirects 188 | } 189 | 190 | /** 191 | * Find if the response has parsed body. The check is performed 192 | * by inspecting the response content-type and returns true 193 | * when content-type is either one of the following. 194 | * 195 | * - application/json 196 | * - application/x-www-form-urlencoded 197 | * - multipart/form-data 198 | * 199 | * Or when the response body is a buffer. 200 | */ 201 | hasBody(): boolean { 202 | return ( 203 | this.type() === 'application/json' || 204 | this.type() === 'application/x-www-form-urlencoded' || 205 | this.type() === 'multipart/form-data' || 206 | Buffer.isBuffer(this.response.body) 207 | ) 208 | } 209 | 210 | /** 211 | * Find if the response body has files 212 | */ 213 | hasFiles(): boolean { 214 | return this.files() && Object.keys(this.files()).length > 0 215 | } 216 | 217 | /** 218 | * Find if response is an error 219 | */ 220 | hasError(): boolean { 221 | return this.error() ? true : false 222 | } 223 | 224 | /** 225 | * Find if response is an fatal error. Response with >=500 226 | * status code are concerned as fatal errors 227 | */ 228 | hasFatalError(): boolean { 229 | return this.status() >= 500 230 | } 231 | 232 | /** 233 | * Find if the request client failed to make the request 234 | */ 235 | hasClientError(): boolean { 236 | return this.response.clientError 237 | } 238 | 239 | /** 240 | * Find if the server responded with an error 241 | */ 242 | hasServerError(): boolean { 243 | return this.response.serverError 244 | } 245 | 246 | /** 247 | * Access to response error 248 | */ 249 | error(): false | HTTPError { 250 | return this.response.error 251 | } 252 | 253 | /** 254 | * Get cookie by name 255 | */ 256 | cookie(name: string): ResponseCookie | undefined { 257 | return this.cookiesJar[name] 258 | } 259 | 260 | /** 261 | * Parsed response cookies 262 | */ 263 | cookies() { 264 | return this.cookiesJar 265 | } 266 | 267 | /** 268 | * Dump request headers 269 | */ 270 | dumpHeaders(): this { 271 | if (this.#valuesDumped.has('headers')) { 272 | return this 273 | } 274 | 275 | this.#valuesDumped.add('headers') 276 | dumpResponseHeaders(this) 277 | return this 278 | } 279 | 280 | /** 281 | * Dump request cookies 282 | */ 283 | dumpCookies(): this { 284 | if (this.#valuesDumped.has('cookies')) { 285 | return this 286 | } 287 | 288 | this.#valuesDumped.add('cookies') 289 | dumpResponseCookies(this) 290 | return this 291 | } 292 | 293 | /** 294 | * Dump request body 295 | */ 296 | dumpBody(): this { 297 | if (this.#valuesDumped.has('body')) { 298 | return this 299 | } 300 | 301 | this.#valuesDumped.add('body') 302 | dumpResponseBody(this) 303 | return this 304 | } 305 | 306 | /** 307 | * Dump request body 308 | */ 309 | dumpError(): this { 310 | if (this.#valuesDumped.has('error')) { 311 | return this 312 | } 313 | 314 | this.#valuesDumped.add('error') 315 | dumpResponseError(this) 316 | return this 317 | } 318 | 319 | /** 320 | * Dump request 321 | */ 322 | dump(): this { 323 | if (this.#valuesDumped.has('response')) { 324 | return this 325 | } 326 | 327 | this.#valuesDumped.add('response') 328 | dumpResponse(this) 329 | this.dumpCookies() 330 | this.dumpHeaders() 331 | this.dumpBody() 332 | this.dumpError() 333 | return this 334 | } 335 | 336 | /** 337 | * Assert response status to match the expected status 338 | */ 339 | assertStatus(expectedStatus: number) { 340 | this.#ensureHasAssert() 341 | this.assert!.equal(this.status(), expectedStatus) 342 | } 343 | 344 | /** 345 | * Assert response body to match the expected body 346 | */ 347 | assertBody(expectedBody: any) { 348 | this.#ensureHasAssert() 349 | this.assert!.deepEqual(this.body(), expectedBody) 350 | } 351 | 352 | /** 353 | * Assert response body to match the subset from the 354 | * expected body 355 | */ 356 | assertBodyContains(expectedBody: any) { 357 | this.#ensureHasAssert() 358 | this.assert!.containsSubset(this.body(), expectedBody) 359 | } 360 | 361 | /** 362 | * Assert response body not to match the subset from the 363 | * expected body 364 | */ 365 | assertBodyNotContains(expectedBody: any) { 366 | this.#ensureHasAssert() 367 | this.assert!.notContainsSubset(this.body(), expectedBody) 368 | } 369 | 370 | /** 371 | * Assert response to contain a given cookie and optionally 372 | * has the expected value 373 | */ 374 | assertCookie(name: string, value?: any) { 375 | this.#ensureHasAssert() 376 | this.assert!.property(this.cookies(), name) 377 | 378 | if (value !== undefined) { 379 | this.assert!.deepEqual(this.cookie(name)!.value, value) 380 | } 381 | } 382 | 383 | /** 384 | * Assert response to not contain a given cookie 385 | */ 386 | assertCookieMissing(name: string) { 387 | this.#ensureHasAssert() 388 | this.assert!.notProperty(this.cookies(), name) 389 | } 390 | 391 | /** 392 | * Assert response to contain a given header and optionally 393 | * has the expected value 394 | */ 395 | assertHeader(name: string, value?: any) { 396 | name = name.toLowerCase() 397 | this.#ensureHasAssert() 398 | this.assert!.property(this.headers(), name) 399 | 400 | if (value !== undefined) { 401 | this.assert!.deepEqual(this.header(name), value) 402 | } 403 | } 404 | 405 | /** 406 | * Assert response to not contain a given header 407 | */ 408 | assertHeaderMissing(name: string) { 409 | name = name.toLowerCase() 410 | this.#ensureHasAssert() 411 | this.assert!.notProperty(this.headers(), name) 412 | } 413 | 414 | /** 415 | * Assert response text to include the expected value 416 | */ 417 | assertTextIncludes(expectedSubset: string) { 418 | this.#ensureHasAssert() 419 | this.assert!.include(this.text(), expectedSubset) 420 | } 421 | 422 | /** 423 | * Assert response body is valid as per the API spec. 424 | */ 425 | assertAgainstApiSpec() { 426 | this.#ensureHasOpenAPIAssertions() 427 | this.assert!.isValidApiResponse(this.response) 428 | } 429 | 430 | /** 431 | * Assert there is a matching redirect 432 | */ 433 | assertRedirectsTo(pathname: string) { 434 | this.#ensureHasAssert() 435 | const redirects = this.redirects().map((url) => new URL(url).pathname) 436 | 437 | this.assert!.evaluate( 438 | redirects.find((one) => one === pathname), 439 | `Expected #{exp} to be one of #{act}`, 440 | { 441 | expected: [pathname], 442 | actual: redirects, 443 | operator: 'includes', 444 | } 445 | ) 446 | } 447 | 448 | /** 449 | * Assert that response has an ok (200) status 450 | */ 451 | assertOk() { 452 | this.assertStatus(200) 453 | } 454 | 455 | /** 456 | * Assert that response has a created (201) status 457 | */ 458 | assertCreated() { 459 | this.assertStatus(201) 460 | } 461 | 462 | /** 463 | * Assert that response has an accepted (202) status 464 | */ 465 | assertAccepted() { 466 | this.assertStatus(202) 467 | } 468 | 469 | /** 470 | * Assert that response has a no content (204) status 471 | */ 472 | assertNoContent() { 473 | this.assertStatus(204) 474 | } 475 | 476 | /** 477 | * Assert that response has a moved permanently (301) status 478 | */ 479 | assertMovedPermanently() { 480 | this.assertStatus(301) 481 | } 482 | 483 | /** 484 | * Assert that response has a found (302) status 485 | */ 486 | assertFound() { 487 | this.assertStatus(302) 488 | } 489 | 490 | /** 491 | * Assert that response has a bad request (400) status 492 | */ 493 | assertBadRequest() { 494 | this.assertStatus(400) 495 | } 496 | 497 | /** 498 | * Assert that response has an unauthorized (401) status 499 | */ 500 | assertUnauthorized() { 501 | this.assertStatus(401) 502 | } 503 | 504 | /** 505 | * Assert that response has a payment required (402) status 506 | */ 507 | assertPaymentRequired() { 508 | this.assertStatus(402) 509 | } 510 | 511 | /** 512 | * Assert that response has a forbidden (403) status 513 | */ 514 | assertForbidden() { 515 | this.assertStatus(403) 516 | } 517 | 518 | /** 519 | * Assert that response has a not found (404) status 520 | */ 521 | assertNotFound() { 522 | this.assertStatus(404) 523 | } 524 | 525 | /** 526 | * Assert that response has a method not allowed (405) status 527 | */ 528 | assertMethodNotAllowed() { 529 | this.assertStatus(405) 530 | } 531 | 532 | /** 533 | * Assert that response has a not acceptable (406) status 534 | */ 535 | assertNotAcceptable() { 536 | this.assertStatus(406) 537 | } 538 | 539 | /** 540 | * Assert that response has a request timeout (408) status 541 | */ 542 | assertRequestTimeout() { 543 | this.assertStatus(408) 544 | } 545 | 546 | /** 547 | * Assert that response has a conflict (409) status 548 | */ 549 | assertConflict() { 550 | this.assertStatus(409) 551 | } 552 | 553 | /** 554 | * Assert that response has a gone (410) status 555 | */ 556 | assertGone() { 557 | this.assertStatus(410) 558 | } 559 | 560 | /** 561 | * Assert that response has a length required (411) status 562 | */ 563 | assertLengthRequired() { 564 | this.assertStatus(411) 565 | } 566 | 567 | /** 568 | * Assert that response has a precondition failed (412) status 569 | */ 570 | assertPreconditionFailed() { 571 | this.assertStatus(412) 572 | } 573 | 574 | /** 575 | * Assert that response has a payload too large (413) status 576 | */ 577 | assertPayloadTooLarge() { 578 | this.assertStatus(413) 579 | } 580 | 581 | /** 582 | * Assert that response has a URI too long (414) status 583 | */ 584 | assertURITooLong() { 585 | this.assertStatus(414) 586 | } 587 | 588 | /** 589 | * Assert that response has an unsupported media type (415) status 590 | */ 591 | assertUnsupportedMediaType() { 592 | this.assertStatus(415) 593 | } 594 | 595 | /** 596 | * Assert that response has a range not satisfiable (416) status 597 | */ 598 | assertRangeNotSatisfiable() { 599 | this.assertStatus(416) 600 | } 601 | 602 | /** 603 | * Assert that response has an im a teapot (418) status 604 | */ 605 | assertImATeapot() { 606 | this.assertStatus(418) 607 | } 608 | 609 | /** 610 | * Assert that response has an unprocessable entity (422) status 611 | */ 612 | assertUnprocessableEntity() { 613 | this.assertStatus(422) 614 | } 615 | 616 | /** 617 | * Assert that response has a locked (423) status 618 | */ 619 | assertLocked() { 620 | this.assertStatus(423) 621 | } 622 | 623 | /** 624 | * Assert that response has a too many requests (429) status 625 | */ 626 | assertTooManyRequests() { 627 | this.assertStatus(429) 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { ReadStream } from 'node:fs' 11 | import { Response } from 'superagent' 12 | import { EventEmitter } from 'node:events' 13 | 14 | import { ApiRequest } from './request.js' 15 | import { ApiResponse } from './response.js' 16 | 17 | /** 18 | * The interface is copied from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/formidable/PersistentFile.d.ts, since superagent using formidable for parsing response 19 | * files. 20 | */ 21 | export interface SuperAgentResponseFile extends EventEmitter { 22 | open(): void 23 | toJSON(): { 24 | length: number 25 | mimetype: string | null 26 | mtime: Date | null 27 | size: number 28 | filepath: string 29 | originalFilename: string | null 30 | hash?: string | null 31 | } 32 | toString(): string 33 | write(buffer: string, cb: () => void): void 34 | end(cb: () => void): void 35 | destroy(): void 36 | } 37 | 38 | /** 39 | * Superagent response parser callback method. The method 40 | * receives an instance of the Node.js readable stream 41 | */ 42 | export type SuperAgentParser = ( 43 | res: Response, 44 | callback: (err: Error | null, body: any) => void 45 | ) => void 46 | 47 | /** 48 | * Superagent request serializer. The method receives the 49 | * request body object and must serialize it to a string 50 | */ 51 | export type SuperAgentSerializer = (obj: any) => string 52 | 53 | /** 54 | * Allowed multipart values 55 | */ 56 | export type MultipartValue = Blob | Buffer | ReadStream | string | boolean | number 57 | 58 | /** 59 | * Shape of custom cookies serializer. 60 | */ 61 | export type CookiesSerializer = { 62 | process(key: string, value: any, response: ApiResponse): any 63 | prepare(key: string, value: any, request: ApiRequest): string 64 | } 65 | 66 | /** 67 | * Config accepted by the API request class 68 | */ 69 | export type RequestConfig = { 70 | method: string 71 | endpoint: string 72 | baseUrl?: string 73 | hooks?: { 74 | setup: SetupHandler[] 75 | teardown: TeardownHandler[] 76 | } 77 | serializers?: { 78 | cookie?: CookiesSerializer 79 | } 80 | } 81 | 82 | /** 83 | * Shape of the parsed response cookie 84 | */ 85 | export type ResponseCookie = { 86 | name: string 87 | value: any 88 | path?: string 89 | domain?: string 90 | expires?: Date 91 | maxAge?: number 92 | secure?: boolean 93 | httpOnly?: boolean 94 | sameSite?: string 95 | } 96 | 97 | /** 98 | * Response cookies jar 99 | */ 100 | export type ResponseCookies = Record 101 | 102 | /** 103 | * Shape of the cookie accepted by the request 104 | */ 105 | export type RequestCookie = { 106 | name: string 107 | value: any 108 | } 109 | 110 | /** 111 | * Request cookies jar 112 | */ 113 | export type RequestCookies = Record 114 | 115 | /** 116 | * Setup handlers 117 | */ 118 | export type SetupCleanupHandler = (error: any | null, request: ApiRequest) => any | Promise 119 | export type SetupHandler = ( 120 | request: ApiRequest 121 | ) => any | SetupCleanupHandler | Promise | Promise 122 | 123 | /** 124 | * Teardown handlers 125 | */ 126 | export type TeardownCleanupHandler = ( 127 | error: any | null, 128 | response: ApiResponse 129 | ) => any | Promise 130 | export type TeardownHandler = ( 131 | response: ApiResponse 132 | ) => any | TeardownCleanupHandler | Promise | Promise 133 | 134 | /** 135 | * Hooks type 136 | */ 137 | export type ApiRequestHooks = { 138 | setup: [Parameters, Parameters] 139 | teardown: [Parameters, Parameters] 140 | } 141 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { inspect } from 'node:util' 11 | import { ApiRequest } from './request.js' 12 | import { ApiResponse } from './response.js' 13 | import { parse } from 'qs' 14 | 15 | const INSPECT_OPTIONS = { colors: true, depth: 2, showHidden: false } 16 | 17 | /** 18 | * Convert error stack string to an error object. 19 | * 20 | * It is an expirement to use server error stack and convert 21 | * it to an actual error object. 22 | */ 23 | export function stackToError(errorStack: any): string | Error { 24 | if (typeof errorStack === 'string' && /^\s*at .*(\S+:\d+|\(native\))/m.test(errorStack)) { 25 | const customError = new Error(errorStack.split('\n')[0]) 26 | customError.stack = errorStack 27 | return customError 28 | } 29 | 30 | return errorStack 31 | } 32 | 33 | /** 34 | * Default implementation to print request errors 35 | */ 36 | export function dumpResponseError(response: ApiResponse) { 37 | /** 38 | * Attempt to convert error stack to a error object when status >= 500 39 | */ 40 | if (response.status() >= 500 && response.hasError()) { 41 | console.log(`"error" => ${inspect(stackToError(response.text()))}`) 42 | return 43 | } 44 | } 45 | 46 | /** 47 | * Default implementation to log request cookies 48 | */ 49 | export function dumpRequestCookies(request: ApiRequest) { 50 | console.log(`"cookies" => ${inspect(request.cookiesJar, INSPECT_OPTIONS)}`) 51 | } 52 | 53 | /** 54 | * Default implementation to log response cookies 55 | */ 56 | export function dumpResponseCookies(response: ApiResponse) { 57 | console.log(`"cookies" => ${inspect(response.cookies(), INSPECT_OPTIONS)}`) 58 | } 59 | 60 | /** 61 | * Default implementation to log request headers 62 | */ 63 | export function dumpRequestHeaders(request: ApiRequest) { 64 | // @ts-ignore 65 | console.log(`"headers" => ${inspect(request.request['header'], INSPECT_OPTIONS)}`) 66 | } 67 | 68 | /** 69 | * Default implementation to log response headers 70 | */ 71 | export function dumpResponseHeaders(response: ApiResponse) { 72 | console.log(`"headers" => ${inspect(response.headers(), INSPECT_OPTIONS)}`) 73 | } 74 | 75 | /** 76 | * Default implementation to log request body 77 | */ 78 | export function dumpRequestBody(request: ApiRequest) { 79 | // @ts-ignore 80 | const data = request.request['_data'] 81 | if (data) { 82 | console.log(`"body" => ${inspect(data, INSPECT_OPTIONS)}`) 83 | } 84 | } 85 | 86 | /** 87 | * Default implementation to log response body 88 | */ 89 | export function dumpResponseBody(response: ApiResponse) { 90 | if (response.status() >= 500) { 91 | return 92 | } 93 | 94 | if (response.hasBody()) { 95 | console.log(`"body" => ${inspect(response.body(), INSPECT_OPTIONS)}`) 96 | } else if (response.text()) { 97 | console.log(`"text" => ${inspect(response.text(), INSPECT_OPTIONS)}`) 98 | } 99 | 100 | if (response.hasFiles()) { 101 | const files = Object.keys(response.files()).reduce( 102 | (result, fileName) => { 103 | result[fileName] = response.files()[fileName].toJSON() 104 | return result 105 | }, 106 | {} as Record 107 | ) 108 | console.log(`"files" => ${inspect(files, INSPECT_OPTIONS)}`) 109 | } 110 | } 111 | 112 | /** 113 | * Default implementation to log request 114 | */ 115 | export function dumpRequest(request: ApiRequest) { 116 | console.log( 117 | `"request" => ${inspect( 118 | { 119 | method: request.request.method, 120 | endpoint: request.config.endpoint, 121 | }, 122 | INSPECT_OPTIONS 123 | )}` 124 | ) 125 | 126 | if ('qsRaw' in request.request && Array.isArray(request.request.qsRaw)) { 127 | console.log(`"qs" => ${inspect(parse(request.request.qsRaw.join('&')), INSPECT_OPTIONS)}`) 128 | } 129 | } 130 | 131 | /** 132 | * Default implementation to log response 133 | */ 134 | export function dumpResponse(response: ApiResponse) { 135 | console.log( 136 | `"response" => ${inspect( 137 | { 138 | status: response.status(), 139 | }, 140 | INSPECT_OPTIONS 141 | )}` 142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /tests/api_client/base.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiClient } from '../../src/client.js' 13 | import { ApiRequest } from '../../src/request.js' 14 | import { httpServer } from '../../tests_helpers/index.js' 15 | import { ApiResponse } from '../../src/response.js' 16 | import { RequestConfig } from '../../src/types.js' 17 | 18 | test.group('API client | request', (group) => { 19 | group.each.setup(async () => { 20 | await httpServer.create() 21 | return () => httpServer.close() 22 | }) 23 | 24 | group.each.setup(() => { 25 | return () => { 26 | ApiClient.clearRequestHandlers() 27 | ApiClient.clearSetupHooks() 28 | ApiClient.clearTeardownHooks() 29 | } 30 | }) 31 | 32 | test('make { method } request using api client') 33 | .with<{ method: RequestConfig['method'] }[]>([ 34 | { method: 'GET' }, 35 | { method: 'POST' }, 36 | { method: 'PUT' }, 37 | { method: 'PATCH' }, 38 | { method: 'DELETE' }, 39 | { method: 'HEAD' }, 40 | { method: 'OPTIONS' }, 41 | ]) 42 | .run(async ({ assert }, { method }) => { 43 | let requestMethod: RequestConfig['method'] 44 | let requestEndpoint: string 45 | 46 | httpServer.onRequest((req, res) => { 47 | requestMethod = method 48 | requestEndpoint = req.url! 49 | res.end('handled') 50 | }) 51 | 52 | // @ts-ignore 53 | const request = new ApiClient(httpServer.baseUrl)[method.toLowerCase()]('/') 54 | const response = await request 55 | 56 | assert.equal(requestMethod!, method) 57 | assert.equal(requestEndpoint!, '/') 58 | 59 | if (method !== 'HEAD') { 60 | assert.equal(response.text(), 'handled') 61 | } 62 | }) 63 | 64 | test('register global setup hooks using the ApiClient', async ({ assert }) => { 65 | const stack: string[] = [] 66 | 67 | httpServer.onRequest((_, res) => { 68 | res.end('handled') 69 | }) 70 | 71 | ApiClient.setup(async (req) => { 72 | assert.instanceOf(req, ApiRequest) 73 | stack.push('setup') 74 | return () => stack.push('setup cleanup') 75 | }) 76 | 77 | const request = new ApiClient(httpServer.baseUrl).get('/') 78 | await request 79 | 80 | assert.deepEqual(stack, ['setup', 'setup cleanup']) 81 | }) 82 | 83 | test('register global teardown hooks using the ApiClient', async ({ assert }) => { 84 | const stack: string[] = [] 85 | 86 | httpServer.onRequest((_, res) => { 87 | res.end('handled') 88 | }) 89 | 90 | ApiClient.setup(async (req) => { 91 | assert.instanceOf(req, ApiRequest) 92 | stack.push('setup') 93 | return () => stack.push('setup cleanup') 94 | }) 95 | 96 | ApiClient.teardown((res) => { 97 | assert.instanceOf(res, ApiResponse) 98 | stack.push('teardown') 99 | return () => stack.push('teardown cleanup') 100 | }) 101 | 102 | const request = new ApiClient(httpServer.baseUrl).get('/') 103 | await request 104 | 105 | assert.deepEqual(stack, ['setup', 'setup cleanup', 'teardown', 'teardown cleanup']) 106 | }) 107 | 108 | test('clear setup hooks', async ({ assert }) => { 109 | const stack: string[] = [] 110 | 111 | httpServer.onRequest((_, res) => { 112 | res.end('handled') 113 | }) 114 | 115 | ApiClient.setup(async (req) => { 116 | assert.instanceOf(req, ApiRequest) 117 | stack.push('setup') 118 | return () => stack.push('setup cleanup') 119 | }) 120 | 121 | ApiClient.clearSetupHooks() 122 | const request = new ApiClient(httpServer.baseUrl).get('/') 123 | await request 124 | 125 | assert.deepEqual(stack, []) 126 | }) 127 | 128 | test('clear teardown hooks', async ({ assert }) => { 129 | const stack: string[] = [] 130 | 131 | httpServer.onRequest((_, res) => { 132 | res.end('handled') 133 | }) 134 | 135 | ApiClient.setup(async (req) => { 136 | assert.instanceOf(req, ApiRequest) 137 | stack.push('setup') 138 | return () => stack.push('setup cleanup') 139 | }) 140 | 141 | ApiClient.teardown((res) => { 142 | assert.instanceOf(res, ApiResponse) 143 | stack.push('teardown') 144 | return () => stack.push('teardown cleanup') 145 | }) 146 | 147 | ApiClient.clearTeardownHooks() 148 | 149 | const request = new ApiClient(httpServer.baseUrl).get('/') 150 | await request 151 | 152 | assert.deepEqual(stack, ['setup', 'setup cleanup']) 153 | }) 154 | 155 | test('use HOST and PORT env variables when no baseUrl is provided', async ({ assert }) => { 156 | httpServer.onRequest((_, res) => { 157 | res.end('handled') 158 | }) 159 | 160 | const request = new ApiClient().get('/') 161 | const response = await request 162 | assert.equal(response.text(), 'handled') 163 | }) 164 | 165 | test('invoke request setup handlers when a request is created', async ({ assert }) => { 166 | assert.plan(1) 167 | httpServer.onRequest((_, res) => { 168 | res.end('handled') 169 | }) 170 | 171 | ApiClient.onRequest((request) => { 172 | assert.instanceOf(request, ApiRequest) 173 | }) 174 | 175 | new ApiClient(httpServer.baseUrl).get('/') 176 | }) 177 | }) 178 | -------------------------------------------------------------------------------- /tests/request/auth.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiRequest } from '../../src/request.js' 13 | import { httpServer } from '../../tests_helpers/index.js' 14 | 15 | test.group('Request | auth', (group) => { 16 | group.each.setup(async () => { 17 | await httpServer.create() 18 | return () => httpServer.close() 19 | }) 20 | 21 | test('login using basic auth', async ({ assert }) => { 22 | httpServer.onRequest((req, res) => { 23 | res.statusCode = 200 24 | const [user, password] = Buffer.from( 25 | req.headers['authorization']!.split('Basic ')[1], 26 | 'base64' 27 | ) 28 | .toString('utf8') 29 | .split(':') 30 | 31 | res.setHeader('content-type', 'application/json') 32 | res.end(JSON.stringify({ user, password })) 33 | }) 34 | 35 | const request = new ApiRequest({ 36 | baseUrl: httpServer.baseUrl, 37 | method: 'GET', 38 | endpoint: '/', 39 | }).dump() 40 | const response = await request.basicAuth('virk', 'secret') 41 | 42 | assert.equal(response.status(), 200) 43 | assert.deepEqual(response.body(), { 44 | user: 'virk', 45 | password: 'secret', 46 | }) 47 | }) 48 | 49 | test('login using bearer token', async ({ assert }) => { 50 | httpServer.onRequest((req, res) => { 51 | res.statusCode = 200 52 | const token = req.headers['authorization']!.split('Bearer ')[1] 53 | res.setHeader('content-type', 'application/json') 54 | res.end(JSON.stringify({ token })) 55 | }) 56 | 57 | const request = new ApiRequest({ 58 | baseUrl: httpServer.baseUrl, 59 | method: 'GET', 60 | endpoint: '/', 61 | }).dump() 62 | const response = await request.bearerToken('foobar') 63 | 64 | assert.equal(response.status(), 200) 65 | assert.deepEqual(response.body(), { 66 | token: 'foobar', 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tests/request/base.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { IncomingMessage } from 'node:http' 12 | 13 | import { ApiRequest } from '../../src/request.js' 14 | import { RequestConfig } from '../../src/types.js' 15 | import { ApiResponse } from '../../src/response.js' 16 | import { httpServer } from '../../tests_helpers/index.js' 17 | 18 | test.group('Request', (group) => { 19 | group.each.setup(async () => { 20 | await httpServer.create() 21 | return () => httpServer.close() 22 | }) 23 | 24 | test('make { method } request to a given URL') 25 | .with<{ method: RequestConfig['method'] }[]>([ 26 | { method: 'GET' }, 27 | { method: 'POST' }, 28 | { method: 'PUT' }, 29 | { method: 'PATCH' }, 30 | { method: 'DELETE' }, 31 | { method: 'HEAD' }, 32 | { method: 'OPTIONS' }, 33 | ]) 34 | .run(async ({ assert }, { method }) => { 35 | let requestMethod: RequestConfig['method'] 36 | let requestEndpoint: string 37 | 38 | httpServer.onRequest((req, res) => { 39 | requestMethod = method 40 | requestEndpoint = req.url! 41 | res.end('handled') 42 | }) 43 | 44 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method, endpoint: '/' }) 45 | const response = await request.dump() 46 | 47 | assert.equal(requestMethod!, method) 48 | assert.equal(requestEndpoint!, '/') 49 | 50 | if (method !== 'HEAD') { 51 | assert.equal(response.text(), 'handled') 52 | } 53 | }) 54 | 55 | test('set accept header for the response', async ({ assert }) => { 56 | httpServer.onRequest(async (req, res) => { 57 | res.statusCode = 200 58 | res.end(req.headers['accept']) 59 | }) 60 | 61 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 62 | const response = await request.accept('json') 63 | assert.equal(response.text(), 'application/json') 64 | }) 65 | 66 | test('abort request after mentioned timeout', async ({ assert }) => { 67 | let serverRequest: IncomingMessage 68 | httpServer.onRequest(async (req) => { 69 | serverRequest = req 70 | }) 71 | 72 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 73 | await assert.rejects(() => request.accept('json').timeout(1000), 'Timeout of 1000ms exceeded') 74 | 75 | /** 76 | * Required to close the HTTP server 77 | */ 78 | serverRequest!.socket.destroy() 79 | }) 80 | 81 | test('retry failing requests for the given number of count', async ({ assert }) => { 82 | let retries = 0 83 | 84 | httpServer.onRequest(async (_, res) => { 85 | retries++ 86 | res.statusCode = 408 87 | res.end() 88 | }) 89 | 90 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 91 | await request.accept('json').retry(3) 92 | 93 | assert.equal(retries, 4) 94 | }) 95 | 96 | test('retry failing request until the callback returns false', async ({ assert }) => { 97 | let retries = 0 98 | 99 | httpServer.onRequest(async (_, res) => { 100 | retries++ 101 | res.statusCode = 408 102 | res.end() 103 | }) 104 | 105 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 106 | await request.accept('json').retry(Number.POSITIVE_INFINITY, () => { 107 | return retries < 6 108 | }) 109 | 110 | assert.equal(retries, 6) 111 | }) 112 | 113 | test('respect max count even when callback returns true', async ({ assert }) => { 114 | let retries = 0 115 | 116 | httpServer.onRequest(async (_, res) => { 117 | retries++ 118 | res.statusCode = 408 119 | res.end() 120 | }) 121 | 122 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 123 | await request.accept('json').retry(3, () => { 124 | return retries < 6 125 | }) 126 | 127 | assert.equal(retries, 4) 128 | }) 129 | 130 | test('access response within the retry callback', async ({ assert }) => { 131 | let retries = 0 132 | 133 | httpServer.onRequest(async (_, res) => { 134 | retries++ 135 | res.statusCode = 408 136 | res.end() 137 | }) 138 | 139 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 140 | await request.accept('json').retry(3, (error, response) => { 141 | assert.isNull(error) 142 | assert.instanceOf(response, ApiResponse) 143 | return retries < 6 144 | }) 145 | 146 | assert.equal(retries, 4) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /tests/request/body.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { stringify, parse } from 'qs' 12 | import { fileURLToPath } from 'node:url' 13 | import { dirname, join } from 'node:path' 14 | 15 | import { ApiRequest } from '../../src/request.js' 16 | import { awaitStream, httpServer } from '../../tests_helpers/index.js' 17 | 18 | test.group('Request | body', (group) => { 19 | group.each.setup(async () => { 20 | await httpServer.create() 21 | return () => httpServer.close() 22 | }) 23 | 24 | test('send form body', async ({ assert }) => { 25 | httpServer.onRequest(async (req, res) => { 26 | const body = await awaitStream(req) 27 | res.statusCode = 200 28 | res.setHeader('content-type', 'application/json') 29 | res.end(JSON.stringify(parse(body))) 30 | }) 31 | 32 | const request = new ApiRequest({ 33 | baseUrl: httpServer.baseUrl, 34 | method: 'GET', 35 | endpoint: '/', 36 | }).dump() 37 | const response = await request.form({ username: 'virk', age: 22 }) 38 | 39 | assert.equal(response.status(), 200) 40 | assert.deepEqual(response.body(), { 41 | username: 'virk', 42 | age: '22', 43 | }) 44 | }) 45 | 46 | test('send form with array values', async ({ assert, cleanup }) => { 47 | httpServer.onRequest(async (req, res) => { 48 | const body = await awaitStream(req) 49 | res.statusCode = 200 50 | res.setHeader('content-type', 'application/json') 51 | res.end(JSON.stringify(parse(body))) 52 | }) 53 | 54 | ApiRequest.addSerializer('application/x-www-form-urlencoded', (value) => stringify(value)) 55 | cleanup(() => ApiRequest.removeParser('application/x-www-form-urlencoded')) 56 | 57 | const request = new ApiRequest({ 58 | baseUrl: httpServer.baseUrl, 59 | method: 'GET', 60 | endpoint: '/', 61 | }).dump() 62 | const response = await request.form({ 63 | 'usernames': ['virk'], 64 | 'emails[]': 'virk@adonisjs.com', 65 | 'age': 22, 66 | }) 67 | 68 | assert.equal(response.status(), 200) 69 | assert.deepEqual(response.body(), { 70 | usernames: ['virk'], 71 | age: '22', 72 | emails: ['virk@adonisjs.com'], 73 | }) 74 | }) 75 | 76 | test('send json body', async ({ assert }) => { 77 | httpServer.onRequest(async (req, res) => { 78 | const body = await awaitStream(req) 79 | res.statusCode = 200 80 | res.setHeader('content-type', 'application/json') 81 | res.end(body) 82 | }) 83 | 84 | const request = new ApiRequest({ 85 | baseUrl: httpServer.baseUrl, 86 | method: 'GET', 87 | endpoint: '/', 88 | }).dump() 89 | const response = await request.json({ username: 'virk', age: 22 }) 90 | 91 | assert.equal(response.status(), 200) 92 | assert.deepEqual(response.body(), { 93 | username: 'virk', 94 | age: 22, 95 | }) 96 | }) 97 | 98 | test('send multipart body', async ({ assert }) => { 99 | assert.plan(3) 100 | 101 | httpServer.onRequest(async (req, res) => { 102 | const body = await awaitStream(req) 103 | 104 | assert.match(req.headers['content-type']!, /multipart\/form-data; boundary/) 105 | assert.match(body, /virk/) 106 | assert.match(body, /22/) 107 | res.statusCode = 200 108 | res.end() 109 | }) 110 | 111 | const request = new ApiRequest({ 112 | baseUrl: httpServer.baseUrl, 113 | method: 'GET', 114 | endpoint: '/', 115 | }).dump() 116 | await request.fields({ username: 'virk', age: 22 }) 117 | }) 118 | 119 | test('attach files', async ({ assert }) => { 120 | assert.plan(4) 121 | 122 | httpServer.onRequest(async (req, res) => { 123 | const body = await awaitStream(req) 124 | 125 | assert.match(req.headers['content-type']!, /multipart\/form-data; boundary/) 126 | assert.match(body, /virk/) 127 | assert.match(body, /22/) 128 | assert.match(body, /filename="package.json"/) 129 | res.statusCode = 200 130 | res.end() 131 | }) 132 | 133 | const request = new ApiRequest({ 134 | baseUrl: httpServer.baseUrl, 135 | method: 'GET', 136 | endpoint: '/', 137 | }).dump() 138 | await request 139 | .fields({ username: 'virk', age: 22 }) 140 | .file('package', join(dirname(fileURLToPath(import.meta.url)), '../../package.json')) 141 | }) 142 | 143 | test('attach files with custom filename', async ({ assert }) => { 144 | assert.plan(4) 145 | 146 | httpServer.onRequest(async (req, res) => { 147 | const body = await awaitStream(req) 148 | 149 | assert.match(req.headers['content-type']!, /multipart\/form-data; boundary/) 150 | assert.match(body, /virk/) 151 | assert.match(body, /22/) 152 | assert.match(body, /filename="pkg.json"/) 153 | res.statusCode = 200 154 | res.end() 155 | }) 156 | 157 | const request = new ApiRequest({ 158 | baseUrl: httpServer.baseUrl, 159 | method: 'GET', 160 | endpoint: '/', 161 | }).dump() 162 | await request 163 | .fields({ username: 'virk', age: 22 }) 164 | .file('package', join(dirname(fileURLToPath(import.meta.url)), '../../package.json'), { 165 | filename: 'pkg.json', 166 | }) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /tests/request/cookies.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import cookie from 'cookie' 11 | import { test } from '@japa/runner' 12 | 13 | import { ApiRequest } from '../../src/request.js' 14 | import { httpServer } from '../../tests_helpers/index.js' 15 | 16 | test.group('Request | cookies', (group) => { 17 | group.each.setup(async () => { 18 | await httpServer.create() 19 | return () => httpServer.close() 20 | }) 21 | 22 | test('send cookie header', async ({ assert }) => { 23 | httpServer.onRequest((req, res) => { 24 | res.statusCode = 200 25 | res.setHeader('content-type', 'application/json') 26 | res.end(JSON.stringify(cookie.parse(req.headers['cookie']!))) 27 | }) 28 | 29 | const request = new ApiRequest({ 30 | baseUrl: httpServer.baseUrl, 31 | method: 'GET', 32 | endpoint: '/', 33 | }).dump() 34 | const response = await request.cookie('name', 'virk').cookie('pass', 'secret') 35 | 36 | assert.equal(response.status(), 200) 37 | assert.deepEqual(response.body(), { 38 | name: 'virk', 39 | pass: 'secret', 40 | }) 41 | }) 42 | 43 | test('prepare cookies using the serializer', async ({ assert }) => { 44 | httpServer.onRequest((req, res) => { 45 | res.statusCode = 200 46 | res.setHeader('content-type', 'application/json') 47 | res.end(JSON.stringify(cookie.parse(req.headers['cookie']!))) 48 | }) 49 | 50 | const request = new ApiRequest({ 51 | baseUrl: httpServer.baseUrl, 52 | method: 'GET', 53 | endpoint: '/', 54 | serializers: { 55 | cookie: { 56 | process() {}, 57 | prepare(_, value) { 58 | return Buffer.from(value).toString('base64') 59 | }, 60 | }, 61 | }, 62 | }).dump() 63 | 64 | const response = await request.cookie('name', 'virk').cookie('pass', 'secret') 65 | 66 | assert.equal(response.status(), 200) 67 | assert.deepEqual(response.body(), { 68 | name: Buffer.from('virk').toString('base64'), 69 | pass: Buffer.from('secret').toString('base64'), 70 | }) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /tests/request/custom_types.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiRequest } from '../../src/request.js' 13 | import { awaitStream, httpServer } from '../../tests_helpers/index.js' 14 | 15 | test.group('Request | custom types', (group) => { 16 | group.each.setup(async () => { 17 | await httpServer.create() 18 | return () => httpServer.close() 19 | }) 20 | 21 | group.each.setup(async () => { 22 | return () => ApiRequest.removeSerializer('application/xyz') 23 | }) 24 | 25 | test('serialize custom data type', async ({ assert }) => { 26 | httpServer.onRequest(async (req, res) => { 27 | const body = await awaitStream(req) 28 | res.statusCode = 200 29 | res.setHeader('content-type', 'application/json') 30 | res.end(JSON.stringify({ value: body })) 31 | }) 32 | 33 | ApiRequest.addSerializer('application/xyz', function (value) { 34 | return `${value.foo}` 35 | }) 36 | 37 | const request = new ApiRequest({ 38 | baseUrl: httpServer.baseUrl, 39 | method: 'GET', 40 | endpoint: '/', 41 | }).dump() 42 | const response = await request.form({ foo: 'bar' }).type('application/xyz') 43 | 44 | assert.equal(response.status(), 200) 45 | assert.deepEqual(response.body(), { 46 | value: 'bar', 47 | }) 48 | }) 49 | 50 | test('report error raised by custom serializer', async ({ assert }) => { 51 | httpServer.onRequest(async (req, res) => { 52 | const body = await awaitStream(req) 53 | res.statusCode = 200 54 | res.setHeader('content-type', 'application/json') 55 | res.end(JSON.stringify({ value: body })) 56 | }) 57 | 58 | ApiRequest.addSerializer('application/xyz', function () { 59 | throw new Error('Failed') 60 | }) 61 | 62 | const request = new ApiRequest({ 63 | baseUrl: httpServer.baseUrl, 64 | method: 'GET', 65 | endpoint: '/', 66 | }).dump() 67 | await assert.rejects(() => request.form({ foo: 'bar' }).type('application/xyz'), 'Failed') 68 | }) 69 | 70 | test('report error when content-type has no serializer', async ({ assert }) => { 71 | httpServer.onRequest(async (req, res) => { 72 | const body = await awaitStream(req) 73 | res.statusCode = 200 74 | res.setHeader('content-type', 'application/json') 75 | res.end(JSON.stringify({ value: body })) 76 | }) 77 | 78 | const request = new ApiRequest({ 79 | baseUrl: httpServer.baseUrl, 80 | method: 'GET', 81 | endpoint: '/', 82 | }).dump() 83 | await assert.rejects( 84 | () => request.form({ foo: 'bar' }).type('application/xyz'), 85 | 'The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received an instance of Object' 86 | ) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /tests/request/headers.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiRequest } from '../../src/request.js' 13 | import { httpServer } from '../../tests_helpers/index.js' 14 | 15 | test.group('Request | headers', (group) => { 16 | group.each.setup(async () => { 17 | await httpServer.create() 18 | return () => httpServer.close() 19 | }) 20 | 21 | test('send custom headers', async ({ assert }) => { 22 | httpServer.onRequest((req, res) => { 23 | res.statusCode = 200 24 | res.setHeader('content-type', 'application/json') 25 | res.end( 26 | JSON.stringify({ 27 | foo: req.headers['x-foo'], 28 | baz: req.headers['x-baz'], 29 | }) 30 | ) 31 | }) 32 | 33 | const request = new ApiRequest({ 34 | baseUrl: httpServer.baseUrl, 35 | method: 'GET', 36 | endpoint: '/', 37 | }).dump() 38 | const response = await request.header('X-Foo', 'bar').header('X-Baz', ['foo', 'bar']) 39 | 40 | assert.equal(response.status(), 200) 41 | assert.deepEqual(response.body(), { 42 | foo: 'bar', 43 | baz: 'foo, bar', 44 | }) 45 | }) 46 | 47 | test('send custom headers as an object', async ({ assert }) => { 48 | httpServer.onRequest((req, res) => { 49 | res.statusCode = 200 50 | res.setHeader('content-type', 'application/json') 51 | res.end( 52 | JSON.stringify({ 53 | foo: req.headers['x-foo'], 54 | baz: req.headers['x-baz'], 55 | }) 56 | ) 57 | }) 58 | 59 | const request = new ApiRequest({ 60 | baseUrl: httpServer.baseUrl, 61 | method: 'GET', 62 | endpoint: '/', 63 | }).dump() 64 | const response = await request.headers({ 'X-Foo': 'bar', 'X-Baz': ['foo', 'bar'] }) 65 | 66 | assert.equal(response.status(), 200) 67 | assert.deepEqual(response.body(), { 68 | foo: 'bar', 69 | baz: 'foo, bar', 70 | }) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /tests/request/lifecycle_hooks.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiRequest } from '../../src/request.js' 13 | import { httpServer } from '../../tests_helpers/index.js' 14 | 15 | test.group('Request | lifecycle hooks', (group) => { 16 | group.each.setup(async () => { 17 | await httpServer.create() 18 | return () => httpServer.close() 19 | }) 20 | 21 | test('execute setup hooks', async ({ assert }) => { 22 | const stack: string[] = [] 23 | 24 | httpServer.onRequest((_, res) => { 25 | res.end() 26 | }) 27 | 28 | const request = new ApiRequest({ 29 | baseUrl: httpServer.baseUrl, 30 | method: 'GET', 31 | endpoint: '/', 32 | }).dump() 33 | 34 | request.setup((req) => { 35 | assert.instanceOf(req, ApiRequest) 36 | stack.push('setup') 37 | return () => stack.push('setup cleanup') 38 | }) 39 | 40 | const response = await request 41 | 42 | assert.equal(response.status(), 200) 43 | assert.deepEqual(stack, ['setup', 'setup cleanup']) 44 | }) 45 | 46 | test('execute cleanup hooks when request fails', async ({ assert }) => { 47 | const stack: string[] = [] 48 | 49 | httpServer.onRequest((_, res) => { 50 | res.end() 51 | }) 52 | 53 | const request = new ApiRequest({ 54 | baseUrl: httpServer.baseUrl, 55 | method: 'GET', 56 | endpoint: '/', 57 | }).dump() 58 | 59 | request.setup((req) => { 60 | assert.instanceOf(req, ApiRequest) 61 | stack.push('setup') 62 | return (error: any) => { 63 | assert.isDefined(error) 64 | assert.notInstanceOf(error, ApiRequest) 65 | stack.push('setup cleanup') 66 | } 67 | }) 68 | 69 | await assert.rejects(async () => request.form({ name: 'virk' }).type('application/xyz')) 70 | assert.deepEqual(stack, ['setup', 'setup cleanup']) 71 | }) 72 | 73 | test('execute cleanup hooks when request passes', async ({ assert }) => { 74 | const stack: string[] = [] 75 | 76 | httpServer.onRequest((_, res) => { 77 | res.end() 78 | }) 79 | 80 | const request = new ApiRequest({ 81 | baseUrl: httpServer.baseUrl, 82 | method: 'GET', 83 | endpoint: '/', 84 | }).dump() 85 | 86 | request.setup((req) => { 87 | assert.instanceOf(req, ApiRequest) 88 | stack.push('setup') 89 | return (error: any) => { 90 | assert.isNull(error) 91 | assert.notInstanceOf(error, ApiRequest) 92 | stack.push('setup cleanup') 93 | } 94 | }) 95 | 96 | await request.form({ name: 'virk' }) 97 | assert.deepEqual(stack, ['setup', 'setup cleanup']) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /tests/request/query_string.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { parse, stringify } from 'qs' 11 | import { test } from '@japa/runner' 12 | 13 | import { ApiRequest } from '../../src/request.js' 14 | import { httpServer } from '../../tests_helpers/index.js' 15 | 16 | test.group('Request | query string', (group) => { 17 | group.each.setup(async () => { 18 | await httpServer.create() 19 | return () => httpServer.close() 20 | }) 21 | 22 | test('pass query string', async ({ assert }) => { 23 | httpServer.onRequest((req, res) => { 24 | res.statusCode = 200 25 | res.setHeader('content-type', 'application/json') 26 | res.end(JSON.stringify(parse(req.url!.split('?')[1]))) 27 | }) 28 | 29 | const request = new ApiRequest({ 30 | baseUrl: httpServer.baseUrl, 31 | method: 'GET', 32 | endpoint: '/', 33 | }).dump() 34 | const response = await request.qs('orderBy', 'id').qs('direction', 'desc') 35 | 36 | assert.equal(response.status(), 200) 37 | assert.deepEqual(response.body(), { 38 | orderBy: 'id', 39 | direction: 'desc', 40 | }) 41 | }) 42 | 43 | test('pass query string as object', async ({ assert }) => { 44 | httpServer.onRequest((req, res) => { 45 | res.statusCode = 200 46 | res.setHeader('content-type', 'application/json') 47 | res.end(JSON.stringify(parse(req.url!.split('?')[1]))) 48 | }) 49 | 50 | const request = new ApiRequest({ 51 | baseUrl: httpServer.baseUrl, 52 | method: 'GET', 53 | endpoint: '/', 54 | }).dump() 55 | const response = await request.qs({ orderBy: 'id', direction: 'desc' }) 56 | 57 | assert.equal(response.status(), 200) 58 | assert.deepEqual(response.body(), { 59 | orderBy: 'id', 60 | direction: 'desc', 61 | }) 62 | }) 63 | 64 | test('pass query string as a string literal', async ({ assert }) => { 65 | httpServer.onRequest((req, res) => { 66 | res.statusCode = 200 67 | res.setHeader('content-type', 'application/json') 68 | res.end(JSON.stringify(parse(req.url!.split('?')[1]))) 69 | }) 70 | 71 | const request = new ApiRequest({ 72 | baseUrl: httpServer.baseUrl, 73 | method: 'GET', 74 | endpoint: '/', 75 | }).dump() 76 | const response = await request.qs('orderBy=id&direction=desc') 77 | 78 | assert.equal(response.status(), 200) 79 | assert.deepEqual(response.body(), { 80 | orderBy: 'id', 81 | direction: 'desc', 82 | }) 83 | }) 84 | 85 | test('specify array values in query string', async ({ assert, cleanup }) => { 86 | httpServer.onRequest((req, res) => { 87 | res.statusCode = 200 88 | res.setHeader('content-type', 'application/json') 89 | res.end(JSON.stringify(parse(req.url!.split('?')[1]))) 90 | }) 91 | 92 | ApiRequest.setQsSerializer((value) => stringify(value)) 93 | cleanup(() => ApiRequest.removeQsSerializer()) 94 | 95 | const request = new ApiRequest({ 96 | baseUrl: httpServer.baseUrl, 97 | method: 'GET', 98 | endpoint: '/', 99 | }).dump() 100 | 101 | const response = await request.qs('ids', ['1']).qs('usernames[]', 'virk') 102 | 103 | assert.equal(response.status(), 200) 104 | assert.deepEqual(response.body(), { 105 | ids: ['1'], 106 | usernames: ['virk'], 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /tests/response/assertions.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import cookie from 'cookie' 11 | import { test } from '@japa/runner' 12 | 13 | import { ApiRequest } from '../../src/request.js' 14 | import { httpServer } from '../../tests_helpers/index.js' 15 | import { ApiResponse } from '../../src/response.js' 16 | 17 | type ExtractAllowed = Pick< 18 | Base, 19 | { 20 | [Key in keyof Base]: Base[Key] extends Condition 21 | ? Key extends `assert${string}` 22 | ? Key 23 | : never 24 | : never 25 | }[keyof Base] 26 | > 27 | 28 | test.group('Response | assertions', (group) => { 29 | group.each.setup(async () => { 30 | await httpServer.create() 31 | return () => httpServer.close() 32 | }) 33 | 34 | test('assert response status { status } shortcut from { method }') 35 | .with< 36 | { 37 | method: NonNullable void>> 38 | status: number 39 | }[] 40 | >([ 41 | { method: 'assertOk', status: 200 }, 42 | { method: 'assertCreated', status: 201 }, 43 | { method: 'assertAccepted', status: 202 }, 44 | { method: 'assertNoContent', status: 204 }, 45 | { method: 'assertMovedPermanently', status: 301 }, 46 | { method: 'assertFound', status: 302 }, 47 | { method: 'assertBadRequest', status: 400 }, 48 | { method: 'assertUnauthorized', status: 401 }, 49 | { method: 'assertPaymentRequired', status: 402 }, 50 | { method: 'assertForbidden', status: 403 }, 51 | { method: 'assertNotFound', status: 404 }, 52 | { method: 'assertMethodNotAllowed', status: 405 }, 53 | { method: 'assertNotAcceptable', status: 406 }, 54 | { method: 'assertRequestTimeout', status: 408 }, 55 | { method: 'assertConflict', status: 409 }, 56 | { method: 'assertGone', status: 410 }, 57 | { method: 'assertLengthRequired', status: 411 }, 58 | { method: 'assertPreconditionFailed', status: 412 }, 59 | { method: 'assertPayloadTooLarge', status: 413 }, 60 | { method: 'assertURITooLong', status: 414 }, 61 | { method: 'assertUnsupportedMediaType', status: 415 }, 62 | { method: 'assertRangeNotSatisfiable', status: 416 }, 63 | { method: 'assertImATeapot', status: 418 }, 64 | { method: 'assertUnprocessableEntity', status: 422 }, 65 | { method: 'assertLocked', status: 423 }, 66 | { method: 'assertTooManyRequests', status: 429 }, 67 | ]) 68 | .run(async ({ assert }, { method, status }) => { 69 | assert.plan(1) 70 | 71 | httpServer.onRequest((_, res) => { 72 | res.statusCode = status 73 | if (status > 300 && status < 303) { 74 | res.setHeader('Location', '/see-this-instead') 75 | } 76 | res.end('handled') 77 | }) 78 | 79 | const request = new ApiRequest( 80 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 81 | assert 82 | ) 83 | 84 | const response = await request 85 | response[method]() 86 | }) 87 | 88 | test('assert response status', async ({ assert }) => { 89 | assert.plan(1) 90 | 91 | httpServer.onRequest((_, res) => { 92 | res.statusCode = 200 93 | res.setHeader('content-type', 'text/html') 94 | res.end('

hello world

') 95 | }) 96 | 97 | const request = new ApiRequest( 98 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 99 | assert 100 | ) 101 | 102 | const response = await request 103 | response.assertStatus(200) 104 | }) 105 | 106 | test('assert response headers', async ({ assert }) => { 107 | assert.plan(5) 108 | 109 | httpServer.onRequest((_, res) => { 110 | res.statusCode = 200 111 | res.setHeader('content-type', 'text/plain') 112 | res.setHeader('Access-Control-Allow-Origin', '*') 113 | res.end('hello world') 114 | }) 115 | 116 | const request = new ApiRequest( 117 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 118 | assert 119 | ) 120 | 121 | const response = await request 122 | response.assertHeader('content-type') 123 | response.assertHeader('Content-Type') 124 | response.assertHeader('access-control-allow-origin') 125 | response.assertHeader('Access-Control-Allow-Origin') 126 | response.assertHeaderMissing('authorization') 127 | }) 128 | 129 | test('assert response body', async ({ assert }) => { 130 | assert.plan(1) 131 | 132 | httpServer.onRequest((_, res) => { 133 | res.statusCode = 200 134 | res.setHeader('content-type', 'application/json') 135 | res.end(JSON.stringify([{ message: 'hello world' }, { message: 'hi world' }])) 136 | }) 137 | 138 | const request = new ApiRequest( 139 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 140 | assert 141 | ) 142 | 143 | const response = await request 144 | response.assertBodyContains([{ message: 'hello world' }, { message: 'hi world' }]) 145 | }) 146 | 147 | test('assert response body subset', async ({ assert }) => { 148 | assert.plan(1) 149 | 150 | httpServer.onRequest((_, res) => { 151 | res.statusCode = 200 152 | res.setHeader('content-type', 'application/json') 153 | res.end( 154 | JSON.stringify([ 155 | { message: 'hello world', time: new Date() }, 156 | { message: 'hi world', time: new Date() }, 157 | ]) 158 | ) 159 | }) 160 | 161 | const request = new ApiRequest( 162 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 163 | assert 164 | ) 165 | 166 | const response = await request 167 | response.assertBodyContains([{ message: 'hello world' }, { message: 'hi world' }]) 168 | }) 169 | 170 | test('assert response body not subset', async ({ assert }) => { 171 | assert.plan(1) 172 | 173 | httpServer.onRequest((_, res) => { 174 | res.statusCode = 200 175 | res.setHeader('content-type', 'application/json') 176 | res.end(JSON.stringify([{ message: 'hello world', time: new Date() }])) 177 | }) 178 | 179 | const request = new ApiRequest( 180 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 181 | assert 182 | ) 183 | 184 | const response = await request 185 | response.assertBodyNotContains([{ message: 'hi world' }]) 186 | }) 187 | 188 | test('assert response body when response is not json', async ({ assert }) => { 189 | httpServer.onRequest((_, res) => { 190 | res.statusCode = 401 191 | res.end(JSON.stringify({ message: 'Unauthorized' })) 192 | }) 193 | 194 | const request = new ApiRequest( 195 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 196 | assert 197 | ) 198 | 199 | const response = await request 200 | assert.throws( 201 | () => response.assertBody({ message: 'Unauthorized' }), 202 | "expected {} to deeply equal { message: 'Unauthorized' }" 203 | ) 204 | }) 205 | 206 | test('assert response cookies', async ({ assert }) => { 207 | httpServer.onRequest((req, res) => { 208 | res.statusCode = 200 209 | res.setHeader('set-cookie', cookie.serialize('foo', 'bar')) 210 | res.end(req.url) 211 | }) 212 | 213 | const request = new ApiRequest( 214 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 215 | assert 216 | ) 217 | 218 | const response = await request 219 | response.assertCookie('foo') 220 | response.assertCookie('foo', 'bar') 221 | response.assertCookieMissing('baz') 222 | }) 223 | 224 | test('raise exception when assert plugin is not installed', async ({ assert }) => { 225 | httpServer.onRequest((req, res) => { 226 | res.statusCode = 200 227 | res.setHeader('set-cookie', cookie.serialize('foo', 'bar')) 228 | res.end(req.url) 229 | }) 230 | 231 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 232 | 233 | const response = await request 234 | assert.throws( 235 | () => response.assertCookie('foo'), 236 | 'Response assertions are not available. Make sure to install the @japa/assert plugin' 237 | ) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /tests/response/base.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiRequest } from '../../src/request.js' 13 | import { httpServer } from '../../tests_helpers/index.js' 14 | 15 | test.group('Response', (group) => { 16 | group.each.setup(async () => { 17 | await httpServer.create() 18 | return () => httpServer.close() 19 | }) 20 | 21 | group.each.setup(async () => { 22 | return () => ApiRequest.removeParser('text/html') 23 | }) 24 | 25 | test('get response content-type', async ({ assert }) => { 26 | httpServer.onRequest((_, res) => { 27 | res.statusCode = 200 28 | res.setHeader('content-type', 'text/html') 29 | res.end() 30 | }) 31 | 32 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 33 | const response = await request 34 | 35 | response.dump() 36 | assert.equal(response.status(), 200) 37 | assert.equal(response.type(), 'text/html') 38 | assert.isUndefined(response.charset()) 39 | }) 40 | 41 | test('get response charset', async ({ assert }) => { 42 | httpServer.onRequest((_, res) => { 43 | res.statusCode = 200 44 | res.setHeader('content-type', 'text/html; charset=utf-8') 45 | res.end() 46 | }) 47 | 48 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 49 | const response = await request 50 | 51 | response.dump() 52 | assert.equal(response.status(), 200) 53 | assert.equal(response.type(), 'text/html') 54 | assert.equal(response.charset(), 'utf-8') 55 | }) 56 | 57 | test('get response status type', async ({ assert }) => { 58 | httpServer.onRequest((_, res) => { 59 | res.statusCode = 404 60 | res.setHeader('content-type', 'text/html; charset=utf-8') 61 | res.end() 62 | }) 63 | 64 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 65 | const response = await request 66 | 67 | response.dump() 68 | assert.equal(response.statusType(), 4) 69 | assert.equal(response.type(), 'text/html') 70 | assert.equal(response.charset(), 'utf-8') 71 | }) 72 | 73 | test('get response links', async ({ assert }) => { 74 | httpServer.onRequest((_, res) => { 75 | res.statusCode = 200 76 | res.setHeader( 77 | 'Link', 78 | '; rel="preconnect", ; rel="preload"' 79 | ) 80 | res.end() 81 | }) 82 | 83 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 84 | const response = await request 85 | 86 | response.dump() 87 | assert.equal(response.status(), 200) 88 | assert.deepEqual(response.links(), { 89 | preconnect: 'https://one.example.com', 90 | preload: 'https://two.example.com', 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /tests/response/cookies.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import cookie from 'cookie' 11 | import { test } from '@japa/runner' 12 | 13 | import { ApiRequest } from '../../src/request.js' 14 | import { httpServer } from '../../tests_helpers/index.js' 15 | 16 | test.group('Response | cookies', (group) => { 17 | group.each.setup(async () => { 18 | await httpServer.create() 19 | return () => httpServer.close() 20 | }) 21 | 22 | test('parse response cookies', async ({ assert }) => { 23 | httpServer.onRequest((req, res) => { 24 | res.statusCode = 200 25 | res.setHeader('set-cookie', cookie.serialize('foo', 'bar')) 26 | res.end(req.url) 27 | }) 28 | 29 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 30 | 31 | const response = await request 32 | response.dump() 33 | 34 | assert.deepEqual(response.cookies(), { foo: { name: 'foo', value: 'bar' } }) 35 | }) 36 | 37 | test('parse multiple response cookies', async ({ assert }) => { 38 | httpServer.onRequest((req, res) => { 39 | res.statusCode = 200 40 | res.setHeader('set-cookie', [cookie.serialize('foo', 'bar'), cookie.serialize('bar', 'baz')]) 41 | res.end(req.url) 42 | }) 43 | 44 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 45 | 46 | const response = await request 47 | response.dump() 48 | 49 | assert.deepEqual(response.cookies(), { 50 | foo: { name: 'foo', value: 'bar' }, 51 | bar: { name: 'bar', value: 'baz' }, 52 | }) 53 | }) 54 | 55 | test('parse cookie attributes', async ({ assert }) => { 56 | httpServer.onRequest((req, res) => { 57 | res.statusCode = 200 58 | res.setHeader('set-cookie', [cookie.serialize('foo', 'bar', { path: '/', maxAge: 3600 })]) 59 | res.end(req.url) 60 | }) 61 | 62 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 63 | 64 | const response = await request 65 | response.dump() 66 | 67 | assert.deepEqual(response.cookies(), { 68 | foo: { name: 'foo', value: 'bar', maxAge: 3600, path: '/' }, 69 | }) 70 | }) 71 | 72 | test('pass cookies to cookie serializer', async ({ assert }) => { 73 | httpServer.onRequest((req, res) => { 74 | res.statusCode = 200 75 | res.setHeader('set-cookie', [ 76 | cookie.serialize('foo', Buffer.from('bar').toString('base64'), { path: '/', maxAge: 3600 }), 77 | ]) 78 | res.end(req.url) 79 | }) 80 | 81 | const request = new ApiRequest({ 82 | baseUrl: httpServer.baseUrl, 83 | method: 'GET', 84 | endpoint: '/', 85 | serializers: { 86 | cookie: { 87 | process(_, value) { 88 | return Buffer.from(value, 'base64').toString('utf8') 89 | }, 90 | prepare(_, value) { 91 | return value 92 | }, 93 | }, 94 | }, 95 | }) 96 | 97 | const response = await request 98 | response.dump() 99 | 100 | assert.deepEqual(response.cookies(), { 101 | foo: { name: 'foo', value: 'bar', maxAge: 3600, path: '/' }, 102 | }) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /tests/response/custom_types.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { load } from 'cheerio' 11 | import { test } from '@japa/runner' 12 | 13 | import { ApiRequest } from '../../src/request.js' 14 | import { httpServer } from '../../tests_helpers/index.js' 15 | 16 | test.group('Response | custom types', (group) => { 17 | group.each.setup(async () => { 18 | await httpServer.create() 19 | return () => httpServer.close() 20 | }) 21 | 22 | group.each.setup(async () => { 23 | return () => ApiRequest.removeParser('text/html') 24 | }) 25 | 26 | test('define custom response parser', async ({ assert }) => { 27 | httpServer.onRequest((_, res) => { 28 | res.statusCode = 200 29 | res.setHeader('content-type', 'text/html') 30 | res.end('

hello world

') 31 | }) 32 | 33 | ApiRequest.addParser('text/html', function (response, cb) { 34 | response.setEncoding('utf-8') 35 | response.text = '' 36 | response.on('data', (chunk) => (response.text += chunk)) 37 | response.on('end', () => cb(null, load(response.text))) 38 | response.on('error', (error) => cb(error, null)) 39 | }) 40 | 41 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 42 | 43 | const response = await request 44 | response.dump() 45 | 46 | assert.equal(response.status(), 200) 47 | assert.isFalse(response.hasBody()) 48 | assert.equal(response.body()('h1').text(), 'hello world') 49 | }) 50 | 51 | test('pass error from the custom parser', async ({ assert }) => { 52 | httpServer.onRequest((_, res) => { 53 | res.statusCode = 200 54 | res.setHeader('content-type', 'text/html') 55 | res.end('

hello world

') 56 | }) 57 | 58 | ApiRequest.addParser('text/html', function (response, cb) { 59 | cb(new Error('Parsing failed'), response) 60 | }) 61 | 62 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 63 | 64 | await assert.rejects(() => request, 'Parsing failed') 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /tests/response/data_types.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { dirname, join } from 'node:path' 11 | import { test } from '@japa/runner' 12 | import { createReadStream } from 'node:fs' 13 | 14 | import { ApiRequest } from '../../src/request.js' 15 | import { awaitStream, httpServer } from '../../tests_helpers/index.js' 16 | import { fileURLToPath } from 'node:url' 17 | 18 | test.group('Response | data types', (group) => { 19 | group.each.setup(async () => { 20 | await httpServer.create() 21 | return () => httpServer.close() 22 | }) 23 | 24 | test('parse html response from the server', async ({ assert }) => { 25 | httpServer.onRequest((_, res) => { 26 | res.statusCode = 200 27 | res.setHeader('content-type', 'text/html') 28 | res.end('

hello world

') 29 | }) 30 | 31 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 32 | 33 | const response = await request 34 | response.dump() 35 | 36 | assert.equal(response.status(), 200) 37 | assert.isFalse(response.hasBody()) 38 | assert.equal(response.text(), '

hello world

') 39 | }) 40 | 41 | test('parse plain text response from the server', async ({ assert }) => { 42 | httpServer.onRequest((_, res) => { 43 | res.statusCode = 200 44 | res.end('hello world') 45 | }) 46 | 47 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 48 | 49 | const response = await request 50 | response.dump() 51 | 52 | assert.equal(response.status(), 200) 53 | assert.isFalse(response.hasBody()) 54 | assert.equal(response.text(), 'hello world') 55 | }) 56 | 57 | test('parse json response from the server', async ({ assert }) => { 58 | httpServer.onRequest((_, res) => { 59 | res.statusCode = 200 60 | res.setHeader('content-type', 'application/json') 61 | res.end(JSON.stringify({ message: 'hello world' })) 62 | }) 63 | 64 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 65 | 66 | const response = await request 67 | response.dump() 68 | 69 | assert.equal(response.status(), 200) 70 | assert.deepEqual(response.body(), { message: 'hello world' }) 71 | }) 72 | 73 | test('parse json response with non 200 code', async ({ assert }) => { 74 | httpServer.onRequest((_, res) => { 75 | res.statusCode = 401 76 | res.setHeader('content-type', 'application/json') 77 | res.end(JSON.stringify({ message: 'Unauthorized' })) 78 | }) 79 | 80 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 81 | 82 | const response = await request 83 | response.dump() 84 | 85 | assert.equal(response.status(), 401) 86 | assert.deepEqual(response.body(), { message: 'Unauthorized' }) 87 | }) 88 | 89 | test('parse streaming response from the server', async ({ assert }) => { 90 | httpServer.onRequest((_, res) => { 91 | res.statusCode = 200 92 | res.setHeader('content-type', 'application/json') 93 | createReadStream(join(dirname(fileURLToPath(import.meta.url)), '../../package.json')).pipe( 94 | res 95 | ) 96 | }) 97 | 98 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 99 | 100 | const response = await request 101 | response.dump() 102 | 103 | assert.equal(response.status(), 200) 104 | assert.properties(response.body(), ['name', 'version']) 105 | assert.equal(response.body().name, '@japa/api-client') 106 | }) 107 | 108 | test('parse binary response from the server', async ({ assert }) => { 109 | httpServer.onRequest((_, res) => { 110 | res.statusCode = 200 111 | res.setHeader('content-type', 'image/png') 112 | createReadStream(join(dirname(fileURLToPath(import.meta.url)), '../../logo.png')).pipe(res) 113 | }) 114 | 115 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 116 | 117 | const response = await request 118 | response.dump() 119 | 120 | assert.equal(response.status(), 200) 121 | assert.isAbove(response.body().length, 1000 * 5) 122 | }) 123 | 124 | test('get unknown content as text', async ({ assert }) => { 125 | httpServer.onRequest((_, res) => { 126 | res.statusCode = 200 127 | res.setHeader('content-type', 'foo/bar') 128 | res.end('

hello world

') 129 | }) 130 | 131 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 132 | 133 | const response = await request 134 | response.dump() 135 | 136 | assert.equal(response.status(), 200) 137 | assert.isFalse(response.hasBody()) 138 | assert.equal(response.text(), '

hello world

') 139 | }) 140 | 141 | test('parse multipart response from the server', async ({ assert }) => { 142 | httpServer.onRequest(async (req, res) => { 143 | const body = await awaitStream(req) 144 | res.statusCode = 200 145 | res.setHeader('content-type', req.headers['content-type']!) 146 | res.end(body) 147 | }) 148 | 149 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 150 | 151 | const response = await request 152 | .fields({ username: 'virk', age: 22 }) 153 | .file('package', join(dirname(fileURLToPath(import.meta.url)), '../../package.json')) 154 | 155 | response.dump() 156 | assert.property(response.files(), 'package') 157 | }).skip( 158 | true, 159 | 'Multipart responses are not parsed from superagent@9.0 because of breaking changes in formidable' 160 | ) 161 | }) 162 | -------------------------------------------------------------------------------- /tests/response/error_handling.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiRequest } from '../../src/request.js' 13 | import { httpServer } from '../../tests_helpers/index.js' 14 | 15 | test.group('Response | error handling', (group) => { 16 | group.each.setup(async () => { 17 | await httpServer.create() 18 | return () => httpServer.close() 19 | }) 20 | 21 | test('dump errors raised by the server', async ({ assert }) => { 22 | httpServer.onRequest((_, res) => { 23 | try { 24 | throw new Error('Something went wrong') 25 | } catch (error) { 26 | res.statusCode = 401 27 | res.setHeader('content-type', 'application/json') 28 | res.end(JSON.stringify({ error: 'UnAuthorized' })) 29 | } 30 | }) 31 | 32 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 33 | 34 | const response = await request 35 | response.dump() 36 | assert.deepEqual(response.body(), { error: 'UnAuthorized' }) 37 | assert.equal(response.status(), 401) 38 | }) 39 | 40 | test('returns ApiResponse for 500 errors', async ({ assert }) => { 41 | httpServer.onRequest((_, res) => { 42 | res.statusCode = 500 43 | res.end('Internal server error') 44 | }) 45 | 46 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 47 | const response = await request 48 | 49 | assert.equal(response.status(), 500) 50 | assert.isTrue(response.hasFatalError()) 51 | assert.isTrue(response.hasServerError()) 52 | }) 53 | 54 | test('handles server errors with response body', async ({ assert }) => { 55 | httpServer.onRequest((_, res) => { 56 | res.statusCode = 500 57 | res.setHeader('Content-Type', 'application/json') 58 | res.end(JSON.stringify({ error: 'Something went wrong', code: 'INTERNAL_ERROR' })) 59 | }) 60 | 61 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 62 | const response = await request 63 | 64 | assert.equal(response.status(), 500) 65 | assert.deepEqual(response.body(), { 66 | error: 'Something went wrong', 67 | code: 'INTERNAL_ERROR', 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /tests/response/lifecycle_hooks.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiRequest } from '../../src/request.js' 13 | import { ApiResponse } from '../../src/response.js' 14 | import { httpServer } from '../../tests_helpers/index.js' 15 | 16 | test.group('Response | lifecycle hooks', (group) => { 17 | group.each.setup(async () => { 18 | await httpServer.create() 19 | return () => httpServer.close() 20 | }) 21 | 22 | test('execute teardown hooks', async ({ assert }) => { 23 | const stack: string[] = [] 24 | 25 | httpServer.onRequest((_, res) => { 26 | res.end() 27 | }) 28 | 29 | const request = new ApiRequest({ 30 | baseUrl: httpServer.baseUrl, 31 | method: 'GET', 32 | endpoint: '/', 33 | }).dump() 34 | 35 | request.setup((req) => { 36 | assert.instanceOf(req, ApiRequest) 37 | stack.push('setup') 38 | return () => stack.push('setup cleanup') 39 | }) 40 | 41 | request.teardown((res) => { 42 | assert.instanceOf(res, ApiResponse) 43 | stack.push('teardown') 44 | return (error: any) => { 45 | assert.isNull(error) 46 | stack.push('teardown cleanup') 47 | } 48 | }) 49 | 50 | const response = await request 51 | 52 | assert.equal(response.status(), 200) 53 | assert.deepEqual(stack, ['setup', 'setup cleanup', 'teardown', 'teardown cleanup']) 54 | }) 55 | 56 | test('do not execute teardown when request fails', async ({ assert }) => { 57 | const stack: string[] = [] 58 | 59 | httpServer.onRequest((_, res) => { 60 | res.end() 61 | }) 62 | 63 | const request = new ApiRequest({ 64 | baseUrl: httpServer.baseUrl, 65 | method: 'GET', 66 | endpoint: '/', 67 | }).dump() 68 | 69 | request.setup((req) => { 70 | assert.instanceOf(req, ApiRequest) 71 | stack.push('setup') 72 | return () => stack.push('setup cleanup') 73 | }) 74 | 75 | request.teardown((res) => { 76 | assert.instanceOf(res, ApiResponse) 77 | stack.push('teardown') 78 | return () => stack.push('teardown cleanup') 79 | }) 80 | 81 | await assert.rejects(async () => request.form({ name: 'virk' }).type('application/xyz')) 82 | 83 | assert.deepEqual(stack, ['setup', 'setup cleanup']) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /tests/response/redirects.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { ApiRequest } from '../../src/request.js' 13 | import { httpServer } from '../../tests_helpers/index.js' 14 | 15 | test.group('Response | redirects', (group) => { 16 | group.each.setup(async () => { 17 | await httpServer.create() 18 | return () => httpServer.close() 19 | }) 20 | 21 | test('follow redirects', async ({ assert }) => { 22 | httpServer.onRequest((req, res) => { 23 | if (req.url === '/') { 24 | res.statusCode = 301 25 | res.setHeader('Location', '/see-this-instead') 26 | res.end() 27 | } else { 28 | res.statusCode = 200 29 | res.end(req.url) 30 | } 31 | }) 32 | 33 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 34 | 35 | const response = await request 36 | response.dump() 37 | 38 | assert.equal(response.status(), 200) 39 | assert.deepEqual(response.redirects(), [`${httpServer.baseUrl}/see-this-instead`]) 40 | assert.equal(response.text(), '/see-this-instead') 41 | }) 42 | 43 | test('do not follow redirects more than the mentioned times', async ({ assert }) => { 44 | httpServer.onRequest((req, res) => { 45 | if (req.url === '/') { 46 | res.statusCode = 301 47 | res.setHeader('Location', '/see-this-instead') 48 | res.end() 49 | } else if (req.url === '/see-this-instead') { 50 | res.statusCode = 301 51 | res.setHeader('Location', '/see-that-instead') 52 | res.end() 53 | } else { 54 | res.statusCode = 200 55 | res.end(req.url) 56 | } 57 | }) 58 | 59 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 60 | 61 | const response = await request.redirects(1) 62 | response.dump() 63 | 64 | assert.equal(response.status(), 301) 65 | assert.deepEqual(response.redirects(), [`${httpServer.baseUrl}/see-this-instead`]) 66 | assert.equal(response.text(), '') 67 | }) 68 | 69 | test('follow redirect multiple times', async ({ assert }) => { 70 | httpServer.onRequest((req, res) => { 71 | if (req.url === '/') { 72 | res.statusCode = 301 73 | res.setHeader('Location', '/see-this-instead') 74 | res.end() 75 | } else if (req.url === '/see-this-instead') { 76 | res.statusCode = 301 77 | res.setHeader('Location', '/see-that-instead') 78 | res.end() 79 | } else { 80 | res.statusCode = 200 81 | res.end(req.url) 82 | } 83 | }) 84 | 85 | const request = new ApiRequest({ baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }) 86 | 87 | const response = await request.redirects(2) 88 | response.dump() 89 | 90 | assert.equal(response.status(), 200) 91 | assert.deepEqual(response.redirects(), [ 92 | `${httpServer.baseUrl}/see-this-instead`, 93 | `${httpServer.baseUrl}/see-that-instead`, 94 | ]) 95 | assert.equal(response.text(), '/see-that-instead') 96 | }) 97 | 98 | test('assert redirects to match the given pathname', async ({ assert }) => { 99 | httpServer.onRequest((req, res) => { 100 | if (req.url === '/') { 101 | res.statusCode = 301 102 | res.setHeader('Location', '/see-this-instead') 103 | res.end() 104 | } else if (req.url === '/see-this-instead') { 105 | res.statusCode = 301 106 | res.setHeader('Location', '/see-that-instead') 107 | res.end() 108 | } else { 109 | res.statusCode = 200 110 | res.end(req.url) 111 | } 112 | }) 113 | 114 | const request = new ApiRequest( 115 | { baseUrl: httpServer.baseUrl, method: 'GET', endpoint: '/' }, 116 | assert 117 | ) 118 | 119 | const response = await request.redirects(2) 120 | response.dump() 121 | 122 | assert.equal(response.status(), 200) 123 | response.assertRedirectsTo('/see-this-instead') 124 | response.assertRedirectsTo('/see-that-instead') 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /tests_helpers/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/api-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Readable } from 'node:stream' 11 | import { createServer, RequestListener, Server } from 'node:http' 12 | 13 | process.env.HOST = 'localhost' 14 | process.env.PORT = '3000' 15 | 16 | class HttpServer { 17 | baseUrl = `http://${process.env.HOST}:${process.env.PORT}` 18 | server?: Server 19 | 20 | close() { 21 | return new Promise((resolve, reject) => { 22 | if (!this.server) { 23 | return resolve() 24 | } 25 | 26 | this.server.close((error) => { 27 | this.server = undefined 28 | if (error) { 29 | reject(error) 30 | } else { 31 | resolve() 32 | } 33 | }) 34 | }) 35 | } 36 | 37 | create() { 38 | return new Promise((resolve) => { 39 | this.server = createServer() 40 | this.server.listen(process.env.PORT, () => { 41 | resolve() 42 | }) 43 | }) 44 | } 45 | 46 | onRequest(listener: RequestListener) { 47 | this.server!.on('request', listener) 48 | } 49 | } 50 | 51 | export const httpServer = new HttpServer() 52 | 53 | export function awaitStream(stream: Readable) { 54 | return new Promise((resolve, reject) => { 55 | let buffer = '' 56 | stream.on('data', (chunk) => (buffer += chunk)) 57 | stream.on('end', () => resolve(buffer)) 58 | stream.on('error', (error) => reject(error)) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | --------------------------------------------------------------------------------