UNPKG

@japa/api-client

Version:

Browser and API testing client for Japa. Built on top of Playwright

1,294 lines (1,286 loc) 30.2 kB
// src/client.ts import Macroable3 from "@poppinss/macroable"; // src/request.ts import cookie from "cookie"; import Hooks from "@poppinss/hooks"; import Macroable2 from "@poppinss/macroable"; import superagent from "superagent"; // src/response.ts import Macroable from "@poppinss/macroable"; import setCookieParser from "set-cookie-parser"; // src/utils.ts import { inspect } from "node:util"; import { parse } from "qs"; var INSPECT_OPTIONS = { colors: true, depth: 2, showHidden: false }; function stackToError(errorStack) { if (typeof errorStack === "string" && /^\s*at .*(\S+:\d+|\(native\))/m.test(errorStack)) { const customError = new Error(errorStack.split("\n")[0]); customError.stack = errorStack; return customError; } return errorStack; } function dumpResponseError(response) { if (response.status() >= 500 && response.hasError()) { console.log(`"error" => ${inspect(stackToError(response.text()))}`); return; } } function dumpRequestCookies(request) { console.log(`"cookies" => ${inspect(request.cookiesJar, INSPECT_OPTIONS)}`); } function dumpResponseCookies(response) { console.log(`"cookies" => ${inspect(response.cookies(), INSPECT_OPTIONS)}`); } function dumpRequestHeaders(request) { console.log(`"headers" => ${inspect(request.request["header"], INSPECT_OPTIONS)}`); } function dumpResponseHeaders(response) { console.log(`"headers" => ${inspect(response.headers(), INSPECT_OPTIONS)}`); } function dumpRequestBody(request) { const data = request.request["_data"]; if (data) { console.log(`"body" => ${inspect(data, INSPECT_OPTIONS)}`); } } function dumpResponseBody(response) { if (response.status() >= 500) { return; } if (response.hasBody()) { console.log(`"body" => ${inspect(response.body(), INSPECT_OPTIONS)}`); } else if (response.text()) { console.log(`"text" => ${inspect(response.text(), INSPECT_OPTIONS)}`); } if (response.hasFiles()) { const files = Object.keys(response.files()).reduce( (result, fileName) => { result[fileName] = response.files()[fileName].toJSON(); return result; }, {} ); console.log(`"files" => ${inspect(files, INSPECT_OPTIONS)}`); } } function dumpRequest(request) { console.log( `"request" => ${inspect( { method: request.request.method, endpoint: request.config.endpoint }, INSPECT_OPTIONS )}` ); if ("qsRaw" in request.request && Array.isArray(request.request.qsRaw)) { console.log(`"qs" => ${inspect(parse(request.request.qsRaw.join("&")), INSPECT_OPTIONS)}`); } } function dumpResponse(response) { console.log( `"response" => ${inspect( { status: response.status() }, INSPECT_OPTIONS )}` ); } // src/response.ts var ApiResponse = class extends Macroable { constructor(request, response, config, assert) { super(); this.request = request; this.response = response; this.config = config; this.assert = assert; this.cookiesJar = this.#parseCookies(); this.#processCookies(); } #valuesDumped = /* @__PURE__ */ new Set(); /** * Parsed cookies */ cookiesJar; /** * Parse response header to collect cookies */ #parseCookies() { const cookieHeader = this.header("set-cookie"); if (!cookieHeader) { return {}; } return setCookieParser.parse(cookieHeader, { map: true }); } /** * Process cookies using the serializer */ #processCookies() { const cookiesSerializer = this.config.serializers?.cookie; const processMethod = cookiesSerializer?.process; if (!processMethod) { return; } Object.keys(this.cookiesJar).forEach((key) => { const cookie2 = this.cookiesJar[key]; const processedValue = processMethod(cookie2.name, cookie2.value, this); if (processedValue !== void 0) { cookie2.value = processedValue; } }); } /** * Ensure assert plugin is installed and configured */ #ensureHasAssert() { if (!this.assert) { throw new Error( "Response assertions are not available. Make sure to install the @japa/assert plugin" ); } } /** * Ensure OpenAPI assertions package is installed and * configured */ #ensureHasOpenAPIAssertions() { this.#ensureHasAssert(); if ("isValidApiResponse" in this.assert === false) { throw new Error( "OpenAPI assertions are not available. Make sure to install the @japa/openapi-assertions plugin" ); } } /** * Response content-type charset. Undefined if no charset * is mentioned. */ charset() { return this.response.charset; } /** * Parsed files from the multipart response. */ files() { return this.response.files; } /** * Returns an object of links by parsing the "Link" header. * * @example * Link: <https://one.example.com>; rel="preconnect", <https://two.example.com>; rel="preload" * response.links() * // { * // preconnect: 'https://one.example.com', // preload: 'https://two.example.com', * // } */ links() { return this.response.links; } /** * Response status type */ statusType() { return this.response.statusType; } /** * Request raw parsed text */ text() { return this.response.text; } /** * Response body */ body() { return this.response.body; } /** * Read value for a given response header */ header(key) { key = key.toLowerCase(); return this.response.headers[key]; } /** * Get all response headers */ headers() { return this.response.headers; } /** * Get response status */ status() { return this.response.status; } /** * Get response content-type */ type() { return this.response.type; } /** * Get redirects URLs the request has followed before * getting the response */ redirects() { return this.response.redirects; } /** * Find if the response has parsed body. The check is performed * by inspecting the response content-type and returns true * when content-type is either one of the following. * * - application/json * - application/x-www-form-urlencoded * - multipart/form-data * * Or when the response body is a buffer. */ hasBody() { return this.type() === "application/json" || this.type() === "application/x-www-form-urlencoded" || this.type() === "multipart/form-data" || Buffer.isBuffer(this.response.body); } /** * Find if the response body has files */ hasFiles() { return this.files() && Object.keys(this.files()).length > 0; } /** * Find if response is an error */ hasError() { return this.error() ? true : false; } /** * Find if response is an fatal error. Response with >=500 * status code are concerned as fatal errors */ hasFatalError() { return this.status() >= 500; } /** * Find if the request client failed to make the request */ hasClientError() { return this.response.clientError; } /** * Find if the server responded with an error */ hasServerError() { return this.response.serverError; } /** * Access to response error */ error() { return this.response.error; } /** * Get cookie by name */ cookie(name) { return this.cookiesJar[name]; } /** * Parsed response cookies */ cookies() { return this.cookiesJar; } /** * Dump request headers */ dumpHeaders() { if (this.#valuesDumped.has("headers")) { return this; } this.#valuesDumped.add("headers"); dumpResponseHeaders(this); return this; } /** * Dump request cookies */ dumpCookies() { if (this.#valuesDumped.has("cookies")) { return this; } this.#valuesDumped.add("cookies"); dumpResponseCookies(this); return this; } /** * Dump request body */ dumpBody() { if (this.#valuesDumped.has("body")) { return this; } this.#valuesDumped.add("body"); dumpResponseBody(this); return this; } /** * Dump request body */ dumpError() { if (this.#valuesDumped.has("error")) { return this; } this.#valuesDumped.add("error"); dumpResponseError(this); return this; } /** * Dump request */ dump() { if (this.#valuesDumped.has("response")) { return this; } this.#valuesDumped.add("response"); dumpResponse(this); this.dumpCookies(); this.dumpHeaders(); this.dumpBody(); this.dumpError(); return this; } /** * Assert response status to match the expected status */ assertStatus(expectedStatus) { this.#ensureHasAssert(); this.assert.equal(this.status(), expectedStatus); } /** * Assert response body to match the expected body */ assertBody(expectedBody) { this.#ensureHasAssert(); this.assert.deepEqual(this.body(), expectedBody); } /** * Assert response body to match the subset from the * expected body */ assertBodyContains(expectedBody) { this.#ensureHasAssert(); this.assert.containsSubset(this.body(), expectedBody); } /** * Assert response body not to match the subset from the * expected body */ assertBodyNotContains(expectedBody) { this.#ensureHasAssert(); this.assert.notContainsSubset(this.body(), expectedBody); } /** * Assert response to contain a given cookie and optionally * has the expected value */ assertCookie(name, value) { this.#ensureHasAssert(); this.assert.property(this.cookies(), name); if (value !== void 0) { this.assert.deepEqual(this.cookie(name).value, value); } } /** * Assert response to not contain a given cookie */ assertCookieMissing(name) { this.#ensureHasAssert(); this.assert.notProperty(this.cookies(), name); } /** * Assert response to contain a given header and optionally * has the expected value */ assertHeader(name, value) { name = name.toLowerCase(); this.#ensureHasAssert(); this.assert.property(this.headers(), name); if (value !== void 0) { this.assert.deepEqual(this.header(name), value); } } /** * Assert response to not contain a given header */ assertHeaderMissing(name) { name = name.toLowerCase(); this.#ensureHasAssert(); this.assert.notProperty(this.headers(), name); } /** * Assert response text to include the expected value */ assertTextIncludes(expectedSubset) { this.#ensureHasAssert(); this.assert.include(this.text(), expectedSubset); } /** * Assert response body is valid as per the API spec. */ assertAgainstApiSpec() { this.#ensureHasOpenAPIAssertions(); this.assert.isValidApiResponse(this.response); } /** * Assert there is a matching redirect */ assertRedirectsTo(pathname) { this.#ensureHasAssert(); const redirects = this.redirects().map((url) => new URL(url).pathname); this.assert.evaluate( redirects.find((one) => one === pathname), `Expected #{exp} to be one of #{act}`, { expected: [pathname], actual: redirects, operator: "includes" } ); } /** * Assert that response has an ok (200) status */ assertOk() { this.assertStatus(200); } /** * Assert that response has a created (201) status */ assertCreated() { this.assertStatus(201); } /** * Assert that response has an accepted (202) status */ assertAccepted() { this.assertStatus(202); } /** * Assert that response has a no content (204) status */ assertNoContent() { this.assertStatus(204); } /** * Assert that response has a moved permanently (301) status */ assertMovedPermanently() { this.assertStatus(301); } /** * Assert that response has a found (302) status */ assertFound() { this.assertStatus(302); } /** * Assert that response has a bad request (400) status */ assertBadRequest() { this.assertStatus(400); } /** * Assert that response has an unauthorized (401) status */ assertUnauthorized() { this.assertStatus(401); } /** * Assert that response has a payment required (402) status */ assertPaymentRequired() { this.assertStatus(402); } /** * Assert that response has a forbidden (403) status */ assertForbidden() { this.assertStatus(403); } /** * Assert that response has a not found (404) status */ assertNotFound() { this.assertStatus(404); } /** * Assert that response has a method not allowed (405) status */ assertMethodNotAllowed() { this.assertStatus(405); } /** * Assert that response has a not acceptable (406) status */ assertNotAcceptable() { this.assertStatus(406); } /** * Assert that response has a request timeout (408) status */ assertRequestTimeout() { this.assertStatus(408); } /** * Assert that response has a conflict (409) status */ assertConflict() { this.assertStatus(409); } /** * Assert that response has a gone (410) status */ assertGone() { this.assertStatus(410); } /** * Assert that response has a length required (411) status */ assertLengthRequired() { this.assertStatus(411); } /** * Assert that response has a precondition failed (412) status */ assertPreconditionFailed() { this.assertStatus(412); } /** * Assert that response has a payload too large (413) status */ assertPayloadTooLarge() { this.assertStatus(413); } /** * Assert that response has a URI too long (414) status */ assertURITooLong() { this.assertStatus(414); } /** * Assert that response has an unsupported media type (415) status */ assertUnsupportedMediaType() { this.assertStatus(415); } /** * Assert that response has a range not satisfiable (416) status */ assertRangeNotSatisfiable() { this.assertStatus(416); } /** * Assert that response has an im a teapot (418) status */ assertImATeapot() { this.assertStatus(418); } /** * Assert that response has an unprocessable entity (422) status */ assertUnprocessableEntity() { this.assertStatus(422); } /** * Assert that response has a locked (423) status */ assertLocked() { this.assertStatus(423); } /** * Assert that response has a too many requests (429) status */ assertTooManyRequests() { this.assertStatus(429); } }; // src/request.ts var DUMP_CALLS = { request: dumpRequest, body: dumpRequestBody, cookies: dumpRequestCookies, headers: dumpRequestHeaders }; var ApiRequest = class _ApiRequest extends Macroable2 { constructor(config, assert) { super(); this.config = config; this.#assert = assert; this.request = this.#createRequest(); this.config.hooks?.setup.forEach((handler) => this.setup(handler)); this.config.hooks?.teardown.forEach((handler) => this.teardown(handler)); } /** * The serializer to use for serializing request query params */ static qsSerializer = (value) => value; /** * Register/remove custom superagent parser, Parsers are used * to parse the incoming response */ static addParser = (contentType, parser) => { superagent.parse[contentType] = parser; }; static removeParser = (contentType) => { delete superagent.parse[contentType]; }; /** * Register/remove custom superagent serializers. Serializers are used * to serialize the request body */ static addSerializer = (contentType, serializer) => { superagent.serialize[contentType] = serializer; }; static removeSerializer = (contentType) => { delete superagent.serialize[contentType]; }; /** * Specify the serializer for query strings. Serializers are used to convert * request querystring values to a string */ static setQsSerializer = (serializer) => { _ApiRequest.qsSerializer = serializer; }; static removeQsSerializer = () => { _ApiRequest.qsSerializer = (value) => value; }; /** * Reference to registered hooks */ hooks = new Hooks(); #setupRunner; #teardownRunner; /** * Reference to Assert module */ #assert; /** * Dump calls */ #valuesToDump = /* @__PURE__ */ new Set(); /** * The underlying super agent request */ request; /** * Cookies to be sent with the request */ cookiesJar = {}; /** * Set cookies header */ #setCookiesHeader() { const prepareMethod = this.config.serializers?.cookie?.prepare; const cookies = Object.keys(this.cookiesJar).map((key) => { let { name, value } = this.cookiesJar[key]; if (prepareMethod) { value = prepareMethod(name, value, this); } return cookie.serialize(name, value); }); if (!cookies.length) { return; } this.header("Cookie", cookies); } /** * Instantiate hooks runner */ #instantiateHooksRunners() { this.#setupRunner = this.hooks.runner("setup"); this.#teardownRunner = this.hooks.runner("teardown"); } /** * Run setup hooks */ async #runSetupHooks() { try { await this.#setupRunner.run(this); } catch (error) { await this.#setupRunner.cleanup(error, this); throw error; } } /** * Run teardown hooks */ async #runTeardownHooks(response) { try { await this.#teardownRunner.run(response); } catch (error) { await this.#teardownRunner.cleanup(error, response); throw error; } await this.#teardownRunner.cleanup(null, response); } /** * Send HTTP request to the server. Errors except the client errors * are tured into a response object. */ async #sendRequest() { let response; try { this.#setCookiesHeader(); this.#dumpValues(); response = await this.request.buffer(true); } catch (error) { this.request.abort(); if (!error.response) { await this.#setupRunner.cleanup(error, this); throw error; } response = error.response; } await this.#setupRunner.cleanup(null, this); return new ApiResponse(this, response, this.config, this.#assert); } /** * Invoke calls calls */ #dumpValues() { if (!this.#valuesToDump.size) { return; } try { this.#valuesToDump.forEach((key) => { DUMP_CALLS[key](this); }); } catch (error) { console.log(error); } } /** * Is endpoint a fully qualified URL or not */ #isUrl(url) { return url.startsWith("http://") || url.startsWith("https://"); } /** * Prepend baseUrl to the endpoint */ #prependBaseUrl(url) { if (!this.config.baseUrl) { return url; } return `${this.config.baseUrl}/${url.replace(/^\//, "")}`; } /** * Creates the request instance for the given HTTP method */ #createRequest() { let url = this.config.endpoint; if (!this.#isUrl(url)) { url = this.#prependBaseUrl(url); } return superagent(this.config.method, url); } /** * Register a setup hook. Setup hooks are called before * making the request */ setup(handler) { this.hooks.add("setup", handler); return this; } /** * Register a teardown hook. Teardown hooks are called after * making the request */ teardown(handler) { this.hooks.add("teardown", handler); return this; } /** * Set cookie as a key-value pair to be sent to the server */ cookie(key, value) { this.cookiesJar[key] = { name: key, value }; return this; } /** * Set cookies as an object to be sent to the server */ cookies(cookies) { Object.keys(cookies).forEach((key) => this.cookie(key, cookies[key])); return this; } /** * Define request header as a key-value pair. * * @example * request.header('x-foo', 'bar') * request.header('x-foo', ['bar', 'baz']) */ header(key, value) { this.headers({ [key]: value }); return this; } /** * Define request headers as an object. * * @example * request.headers({ 'x-foo': 'bar' }) * request.headers({ 'x-foo': ['bar', 'baz'] }) */ headers(headers) { this.request.set(headers); return this; } /** * Define the field value for a multipart request. * * @note: This method makes a multipart request. See [[this.form]] to * make HTML style form submissions. * * @example * request.field('name', 'virk') * request.field('age', 22) */ field(name, value) { this.request.field(name, value); return this; } /** * Define fields as an object for a multipart request * * @note: This method makes a multipart request. See [[this.form]] to * make HTML style form submissions. * * @example * request.fields({'name': 'virk', age: 22}) */ fields(values) { this.request.field(values); return this; } /** * Upload file for a multipart request. Either you can pass path to a * file, a readable stream, or a buffer * * @example * request.file('avatar', 'absolute/path/to/file') * request.file('avatar', createReadStream('./path/to/file')) */ file(name, value, options) { this.request.attach(name, value, options); return this; } /** * Set form values. Calling this method will set the content type * to "application/x-www-form-urlencoded". * * @example * request.form({ * email: 'virk@adonisjs.com', * password: 'secret' * }) */ form(values) { this.type("form"); this.request.send(values); return this; } /** * Set JSON body for the request. Calling this method will set * the content type to "application/json". * * @example * request.json({ * email: 'virk@adonisjs.com', * password: 'secret' * }) */ json(values) { this.type("json"); this.request.send(values); return this; } qs(key, value) { if (!value) { this.request.query(typeof key === "string" ? key : _ApiRequest.qsSerializer(key)); } else { this.request.query(_ApiRequest.qsSerializer({ [key]: value })); } return this; } /** * Set timeout for the request. * * @example * request.timeout(5000) * request.timeout({ response: 5000, deadline: 60000 }) */ timeout(ms) { this.request.timeout(ms); return this; } /** * Set content-type for the request * * @example * request.type('json') */ type(value) { this.request.type(value); return this; } /** * Set "accept" header in the request * * @example * request.accept('json') */ accept(type) { this.request.accept(type); return this; } /** * Follow redirects from the response * * @example * request.redirects(3) */ redirects(count) { this.request.redirects(count); return this; } /** * Set basic auth header from user and password * * @example * request.basicAuth('foo@bar.com', 'secret') */ basicAuth(user, password) { this.request.auth(user, password, { type: "basic" }); return this; } /** * Pass auth bearer token as authorization header. * * @example * request.apiToken('tokenValue') */ bearerToken(token) { this.request.auth(token, { type: "bearer" }); return this; } /** * Set the ca certificates to trust */ ca(certificate) { this.request.ca(certificate); return this; } /** * Set the client certificates */ cert(certificate) { this.request.cert(certificate); return this; } /** * Set the client private key(s) */ privateKey(key) { this.request.key(key); return this; } /** * Set the client PFX or PKCS12 encoded private key and certificate chain */ pfx(key) { this.request.pfx(key); return this; } /** * Does not reject expired or invalid TLS certs. Sets internally rejectUnauthorized=true */ disableTLSCerts() { this.request.disableTLSCerts(); return this; } /** * Trust broken HTTPs connections on localhost */ trustLocalhost(trust = true) { this.request.trustLocalhost(trust); return this; } /** * Dump request headers */ dumpHeaders() { this.#valuesToDump.add("headers"); return this; } /** * Dump request cookies */ dumpCookies() { this.#valuesToDump.add("cookies"); return this; } /** * Dump request body */ dumpBody() { this.#valuesToDump.add("body"); return this; } /** * Dump request */ dump() { this.#valuesToDump.add("request"); this.dumpCookies(); this.dumpHeaders(); this.dumpBody(); return this; } /** * Retry a failing request. Along with the count, you can also define * a callback to decide how long the request should be retried. * * The max count is applied regardless of whether callback is defined * or not * * The following response codes are considered failing. * - 408 * - 413 * - 429 * - 500 * - 502 * - 503 * - 504 * - 521 * - 522 * - 524 * * The following error codes are considered failing. * - 'ETIMEDOUT' * - 'ECONNRESET' * - 'EADDRINUSE' * - 'ECONNREFUSED' * - 'EPIPE' * - 'ENOTFOUND' * - 'ENETUNREACH' * - 'EAI_AGAIN' */ retry(count, retryUntilCallback) { if (retryUntilCallback) { this.request.retry(count, (error, response) => { return retryUntilCallback(error, new ApiResponse(this, response, this.config, this.#assert)); }); return this; } this.request.retry(count); return this; } /** * Make the API request */ async send() { this.#instantiateHooksRunners(); await this.#runSetupHooks(); const response = await this.#sendRequest(); await this.#runTeardownHooks(response); return response; } /** * Implementation of `then` for the promise API */ then(resolve, reject) { return this.send().then(resolve, reject); } /** * Implementation of `catch` for the promise API */ catch(reject) { return this.send().catch(reject); } /** * Implementation of `finally` for the promise API */ finally(fullfilled) { return this.send().finally(fullfilled); } /** * Required when Promises are extended */ get [Symbol.toStringTag]() { return this.constructor.name; } }; // src/client.ts var ApiClient = class extends Macroable3 { /** * Invoked when a new instance of request is created */ static #onRequestHandlers = []; /** * Hooks handlers to pass onto the request */ static #hooksHandlers = { setup: [], teardown: [] }; static #customCookiesSerializer; #baseUrl; #assert; constructor(baseUrl, assert) { super(); this.#baseUrl = baseUrl; this.#assert = assert; } /** * Remove all globally registered setup hooks */ static clearSetupHooks() { this.#hooksHandlers.setup = []; return this; } /** * Remove all globally registered teardown hooks */ static clearTeardownHooks() { this.#hooksHandlers.teardown = []; return this; } /** * Clear on request handlers registered using "onRequest" * method */ static clearRequestHandlers() { this.#onRequestHandlers = []; return this; } /** * Register a handler to be invoked everytime a new request * instance is created */ static onRequest(handler) { this.#onRequestHandlers.push(handler); return this; } /** * Register setup hooks. Setup hooks are called before the request */ static setup(handler) { this.#hooksHandlers.setup.push(handler); return this; } /** * Register teardown hooks. Teardown hooks are called before the request */ static teardown(handler) { this.#hooksHandlers.teardown.push(handler); return this; } /** * Register a custom cookies serializer */ static cookiesSerializer(serailizer) { this.#customCookiesSerializer = serailizer; return this; } /** * Create an instance of the request */ request(endpoint, method) { const hooks = this.constructor.#hooksHandlers; const requestHandlers = this.constructor.#onRequestHandlers; const cookiesSerializer = this.constructor.#customCookiesSerializer; let baseUrl = this.#baseUrl; const envHost = process.env.HOST; const envPort = process.env.PORT; if (!baseUrl && envHost && envPort) { baseUrl = `http://${envHost}:${envPort}`; } const request = new ApiRequest( { baseUrl, method, endpoint, hooks, serializers: { cookie: cookiesSerializer } }, this.#assert ); requestHandlers.forEach((handler) => handler(request)); return request; } /** * Create an instance of the request for GET method */ get(endpoint) { return this.request(endpoint, "GET"); } /** * Create an instance of the request for POST method */ post(endpoint) { return this.request(endpoint, "POST"); } /** * Create an instance of the request for PUT method */ put(endpoint) { return this.request(endpoint, "PUT"); } /** * Create an instance of the request for PATCH method */ patch(endpoint) { return this.request(endpoint, "PATCH"); } /** * Create an instance of the request for DELETE method */ delete(endpoint) { return this.request(endpoint, "DELETE"); } /** * Create an instance of the request for HEAD method */ head(endpoint) { return this.request(endpoint, "HEAD"); } /** * Create an instance of the request for OPTIONS method */ options(endpoint) { return this.request(endpoint, "OPTIONS"); } }; // index.ts import { TestContext } from "@japa/runner/core"; function apiClient(options) { return function() { TestContext.getter( "client", function() { return new ApiClient(typeof options === "string" ? options : options?.baseURL, this.assert); }, true ); }; } export { ApiClient, ApiRequest, ApiResponse, apiClient };