@japa/api-client
Version:
Browser and API testing client for Japa. Built on top of Playwright
724 lines (723 loc) • 18.8 kB
JavaScript
import Macroable from "@poppinss/macroable";
import Hooks from "@poppinss/hooks";
import { serialize } from "cookie-es";
import superagent from "superagent";
import setCookieParser from "set-cookie-parser";
import { inspect } from "node:util";
import { parse } from "@poppinss/qs";
import { TestContext } from "@japa/runner/core";
const 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)}`);
}
var ApiResponse = class extends Macroable {
#valuesDumped = /* @__PURE__ */ new Set();
cookiesJar;
constructor(request, response, config, assert) {
super();
this.request = request;
this.response = response;
this.config = config;
this.assert = assert;
this.cookiesJar = this.#parseCookies();
this.#processCookies();
}
#parseCookies() {
const cookieHeader = this.header("set-cookie");
if (!cookieHeader) return {};
return setCookieParser.parse(cookieHeader, { map: true });
}
#processCookies() {
const processMethod = (this.config.serializers?.cookie)?.process;
if (!processMethod) return;
Object.keys(this.cookiesJar).forEach((key) => {
const cookie = this.cookiesJar[key];
const processedValue = processMethod(cookie.name, cookie.value, this);
if (processedValue !== void 0) cookie.value = processedValue;
});
}
#ensureHasAssert() {
if (!this.assert) throw new Error("Response assertions are not available. Make sure to install the @japa/assert plugin");
}
#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");
}
charset() {
return this.response.charset;
}
files() {
return this.response.files;
}
links() {
return this.response.links;
}
statusType() {
return this.response.statusType;
}
text() {
return this.response.text;
}
body() {
return this.response.body;
}
header(key) {
key = key.toLowerCase();
return this.response.headers[key];
}
headers() {
return this.response.headers;
}
status() {
return this.response.status;
}
type() {
return this.response.type;
}
redirects() {
return this.response.redirects;
}
hasBody() {
return this.type() === "application/json" || this.type() === "application/x-www-form-urlencoded" || this.type() === "multipart/form-data" || Buffer.isBuffer(this.response.body);
}
hasFiles() {
return this.files() && Object.keys(this.files()).length > 0;
}
hasError() {
return this.error() ? true : false;
}
hasFatalError() {
return this.status() >= 500;
}
hasClientError() {
return this.response.clientError;
}
hasServerError() {
return this.response.serverError;
}
error() {
return this.response.error;
}
cookie(name) {
return this.cookiesJar[name];
}
cookies() {
return this.cookiesJar;
}
dumpHeaders() {
if (this.#valuesDumped.has("headers")) return this;
this.#valuesDumped.add("headers");
dumpResponseHeaders(this);
return this;
}
dumpCookies() {
if (this.#valuesDumped.has("cookies")) return this;
this.#valuesDumped.add("cookies");
dumpResponseCookies(this);
return this;
}
dumpBody() {
if (this.#valuesDumped.has("body")) return this;
this.#valuesDumped.add("body");
dumpResponseBody(this);
return this;
}
dumpError() {
if (this.#valuesDumped.has("error")) return this;
this.#valuesDumped.add("error");
dumpResponseError(this);
return this;
}
dump() {
if (this.#valuesDumped.has("response")) return this;
this.#valuesDumped.add("response");
dumpResponse(this);
this.dumpCookies();
this.dumpHeaders();
this.dumpBody();
this.dumpError();
return this;
}
assertStatus(expectedStatus) {
this.#ensureHasAssert();
this.assert.equal(this.status(), expectedStatus);
}
assertBody(expectedBody) {
this.#ensureHasAssert();
this.assert.deepEqual(this.body(), expectedBody);
}
assertBodyContains(expectedBody) {
this.#ensureHasAssert();
this.assert.containsSubset(this.body(), expectedBody);
}
assertBodyNotContains(expectedBody) {
this.#ensureHasAssert();
this.assert.notContainsSubset(this.body(), expectedBody);
}
assertCookie(name, value) {
this.#ensureHasAssert();
this.assert.property(this.cookies(), name);
if (value !== void 0) this.assert.deepEqual(this.cookie(name).value, value);
}
assertCookieMissing(name) {
this.#ensureHasAssert();
this.assert.notProperty(this.cookies(), name);
}
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);
}
assertHeaderMissing(name) {
name = name.toLowerCase();
this.#ensureHasAssert();
this.assert.notProperty(this.headers(), name);
}
assertTextIncludes(expectedSubset) {
this.#ensureHasAssert();
this.assert.include(this.text(), expectedSubset);
}
assertAgainstApiSpec() {
this.#ensureHasOpenAPIAssertions();
this.assert.isValidApiResponse(this.response);
}
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"
});
}
assertOk() {
this.assertStatus(200);
}
assertCreated() {
this.assertStatus(201);
}
assertAccepted() {
this.assertStatus(202);
}
assertNoContent() {
this.assertStatus(204);
}
assertMovedPermanently() {
this.assertStatus(301);
}
assertFound() {
this.assertStatus(302);
}
assertBadRequest() {
this.assertStatus(400);
}
assertUnauthorized() {
this.assertStatus(401);
}
assertPaymentRequired() {
this.assertStatus(402);
}
assertForbidden() {
this.assertStatus(403);
}
assertNotFound() {
this.assertStatus(404);
}
assertMethodNotAllowed() {
this.assertStatus(405);
}
assertNotAcceptable() {
this.assertStatus(406);
}
assertRequestTimeout() {
this.assertStatus(408);
}
assertConflict() {
this.assertStatus(409);
}
assertGone() {
this.assertStatus(410);
}
assertLengthRequired() {
this.assertStatus(411);
}
assertPreconditionFailed() {
this.assertStatus(412);
}
assertPayloadTooLarge() {
this.assertStatus(413);
}
assertURITooLong() {
this.assertStatus(414);
}
assertUnsupportedMediaType() {
this.assertStatus(415);
}
assertRangeNotSatisfiable() {
this.assertStatus(416);
}
assertImATeapot() {
this.assertStatus(418);
}
assertUnprocessableEntity() {
this.assertStatus(422);
}
assertLocked() {
this.assertStatus(423);
}
assertTooManyRequests() {
this.assertStatus(429);
}
};
const DUMP_CALLS = {
request: dumpRequest,
body: dumpRequestBody,
cookies: dumpRequestCookies,
headers: dumpRequestHeaders
};
var ApiRequest = class ApiRequest extends Macroable {
static qsSerializer = (value) => value;
static addParser = (contentType, parser) => {
superagent.parse[contentType] = parser;
};
static removeParser = (contentType) => {
delete superagent.parse[contentType];
};
static addSerializer = (contentType, serializer) => {
superagent.serialize[contentType] = serializer;
};
static removeSerializer = (contentType) => {
delete superagent.serialize[contentType];
};
static setQsSerializer = (serializer) => {
ApiRequest.qsSerializer = serializer;
};
static removeQsSerializer = () => {
ApiRequest.qsSerializer = (value) => value;
};
hooks = new Hooks();
#setupRunner;
#teardownRunner;
#assert;
#valuesToDump = /* @__PURE__ */ new Set();
request;
cookiesJar = {};
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));
}
#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 serialize(name, value);
});
if (!cookies.length) return;
this.header("Cookie", cookies);
}
#instantiateHooksRunners() {
this.#setupRunner = this.hooks.runner("setup");
this.#teardownRunner = this.hooks.runner("teardown");
}
async #runSetupHooks() {
try {
await this.#setupRunner.run(this);
} catch (error) {
await this.#setupRunner.cleanup(error, this);
throw error;
}
}
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);
}
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);
}
#dumpValues() {
if (!this.#valuesToDump.size) return;
try {
this.#valuesToDump.forEach((key) => {
DUMP_CALLS[key](this);
});
} catch (error) {
console.log(error);
}
}
#isUrl(url) {
return url.startsWith("http://") || url.startsWith("https://");
}
#prependBaseUrl(url) {
if (!this.config.baseUrl) return url;
return `${this.config.baseUrl}/${url.replace(/^\//, "")}`;
}
#createRequest() {
let url = this.config.endpoint;
if (!this.#isUrl(url)) url = this.#prependBaseUrl(url);
return superagent(this.config.method, url);
}
setup(handler) {
this.hooks.add("setup", handler);
return this;
}
teardown(handler) {
this.hooks.add("teardown", handler);
return this;
}
cookie(key, value) {
this.cookiesJar[key] = {
name: key,
value
};
return this;
}
cookies(cookies) {
Object.keys(cookies).forEach((key) => this.cookie(key, cookies[key]));
return this;
}
header(key, value) {
this.headers({ [key]: value });
return this;
}
headers(headers) {
this.request.set(headers);
return this;
}
field(name, value) {
this.request.field(name, value);
return this;
}
fields(values) {
this.request.field(values);
return this;
}
file(name, value, options) {
this.request.attach(name, value, options);
return this;
}
form(values) {
this.type("form");
this.request.send(values);
return this;
}
unsafeForm(values) {
this.type("form");
this.request.send(values);
return this;
}
json(values) {
this.type("json");
this.request.send(values);
return this;
}
unsafeJson(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;
}
unsafeQs(key, value) {
if (!value) this.request.query(typeof key === "string" ? key : ApiRequest.qsSerializer(key));
else this.request.query(ApiRequest.qsSerializer({ [key]: value }));
return this;
}
timeout(ms) {
this.request.timeout(ms);
return this;
}
type(value) {
this.request.type(value);
return this;
}
accept(type) {
this.request.accept(type);
return this;
}
redirects(count) {
this.request.redirects(count);
return this;
}
basicAuth(user, password) {
this.request.auth(user, password, { type: "basic" });
return this;
}
bearerToken(token) {
this.request.auth(token, { type: "bearer" });
return this;
}
ca(certificate) {
this.request.ca(certificate);
return this;
}
cert(certificate) {
this.request.cert(certificate);
return this;
}
privateKey(key) {
this.request.key(key);
return this;
}
pfx(key) {
this.request.pfx(key);
return this;
}
disableTLSCerts() {
this.request.disableTLSCerts();
return this;
}
trustLocalhost(trust = true) {
this.request.trustLocalhost(trust);
return this;
}
dumpHeaders() {
this.#valuesToDump.add("headers");
return this;
}
dumpCookies() {
this.#valuesToDump.add("cookies");
return this;
}
dumpBody() {
this.#valuesToDump.add("body");
return this;
}
dump() {
this.#valuesToDump.add("request");
this.dumpCookies();
this.dumpHeaders();
this.dumpBody();
return this;
}
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;
}
async send() {
this.#instantiateHooksRunners();
await this.#runSetupHooks();
const response = await this.#sendRequest();
await this.#runTeardownHooks(response);
return response;
}
then(resolve, reject) {
return this.send().then(resolve, reject);
}
catch(reject) {
return this.send().catch(reject);
}
finally(fullfilled) {
return this.send().finally(fullfilled);
}
get [Symbol.toStringTag]() {
return this.constructor.name;
}
};
var ApiClient = class extends Macroable {
static #onRequestHandlers = [];
static #hooksHandlers = {
setup: [],
teardown: []
};
static #customCookiesSerializer;
static #routesRegistry;
static #patternSerializer = (pattern, params) => {
return pattern.replace(/:(\w+)/g, (_, key) => String(params[key] ?? ""));
};
#baseUrl;
#assert;
constructor(baseUrl, assert) {
super();
this.#baseUrl = baseUrl;
this.#assert = assert;
}
static clearSetupHooks() {
this.#hooksHandlers.setup = [];
return this;
}
static clearTeardownHooks() {
this.#hooksHandlers.teardown = [];
return this;
}
static clearRequestHandlers() {
this.#onRequestHandlers = [];
return this;
}
static onRequest(handler) {
this.#onRequestHandlers.push(handler);
return this;
}
static setup(handler) {
this.#hooksHandlers.setup.push(handler);
return this;
}
static teardown(handler) {
this.#hooksHandlers.teardown.push(handler);
return this;
}
static cookiesSerializer(serailizer) {
this.#customCookiesSerializer = serailizer;
return this;
}
static setRoutes(registry) {
this.#routesRegistry = registry;
return this;
}
static setPatternSerializer(serializer) {
this.#patternSerializer = serializer;
return this;
}
static clearRoutes() {
this.#routesRegistry = void 0;
return this;
}
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;
}
get(endpoint) {
return this.request(endpoint, "GET");
}
post(endpoint) {
return this.request(endpoint, "POST");
}
put(endpoint) {
return this.request(endpoint, "PUT");
}
patch(endpoint) {
return this.request(endpoint, "PATCH");
}
delete(endpoint) {
return this.request(endpoint, "DELETE");
}
head(endpoint) {
return this.request(endpoint, "HEAD");
}
options(endpoint) {
return this.request(endpoint, "OPTIONS");
}
visit(...args) {
const name = args[0];
const params = args[1] ?? {};
const registry = this.constructor.#routesRegistry;
if (!registry) throw new Error(`Routes registry not configured. Use ApiClient.routes() to register your routes.`);
const routeDef = registry[name];
if (!routeDef) throw new Error(`Route "${String(name)}" not found in routes registry.`);
const serializer = this.constructor.#patternSerializer;
const endpoint = serializer(routeDef.pattern, params);
const method = routeDef.methods[0];
return this.request(endpoint, method);
}
};
function apiClient(options) {
return function() {
const normalizedOptions = typeof options === "string" ? { baseURL: options } : options;
if (normalizedOptions?.registry) ApiClient.setRoutes(normalizedOptions.registry);
if (normalizedOptions?.patternSerializer) ApiClient.setPatternSerializer(normalizedOptions.patternSerializer);
TestContext.getter("client", function() {
return new ApiClient(normalizedOptions?.baseURL, this.assert);
}, true);
};
}
export { ApiClient, ApiRequest, ApiResponse, apiClient };