Generate Fern TypeSscript SDK (#3785)

This commit is contained in:
Stanislav Novosad
2025-10-23 20:14:59 -06:00
committed by GitHub
parent d55b9637c4
commit 2062adac66
239 changed files with 14550 additions and 3 deletions

View File

@@ -0,0 +1,29 @@
import type { RequestHandlerOptions } from "msw";
import type { SetupServer } from "msw/node";
import { mockEndpointBuilder } from "./mockEndpointBuilder";
export interface MockServerOptions {
baseUrl: string;
server: SetupServer;
}
export class MockServer {
private readonly server: SetupServer;
public readonly baseUrl: string;
constructor({ baseUrl, server }: MockServerOptions) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
this.server = server;
}
public mockEndpoint(options?: RequestHandlerOptions): ReturnType<typeof mockEndpointBuilder> {
const builder = mockEndpointBuilder({
once: options?.once,
onBuild: (handler) => {
this.server.use(handler);
},
}).baseUrl(this.baseUrl);
return builder;
}
}

View File

@@ -0,0 +1,106 @@
import { setupServer } from "msw/node";
import { fromJson, toJson } from "../../src/core/json";
import { MockServer } from "./MockServer";
import { randomBaseUrl } from "./randomBaseUrl";
const mswServer = setupServer();
interface MockServerOptions {
baseUrl?: string;
}
async function formatHttpRequest(request: Request, id?: string): Promise<string> {
try {
const clone = request.clone();
const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
let body = "";
try {
const contentType = clone.headers.get("content-type");
if (contentType?.includes("application/json")) {
body = toJson(fromJson(await clone.text()), undefined, 2);
} else if (clone.body) {
body = await clone.text();
}
} catch (_e) {
body = "(unable to parse body)";
}
const title = id ? `### Request ${id} ###\n` : "";
const firstLine = `${title}${request.method} ${request.url.toString()} HTTP/1.1`;
return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`;
} catch (e) {
return `Error formatting request: ${e}`;
}
}
async function formatHttpResponse(response: Response, id?: string): Promise<string> {
try {
const clone = response.clone();
const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
let body = "";
try {
const contentType = clone.headers.get("content-type");
if (contentType?.includes("application/json")) {
body = toJson(fromJson(await clone.text()), undefined, 2);
} else if (clone.body) {
body = await clone.text();
}
} catch (_e) {
body = "(unable to parse body)";
}
const title = id ? `### Response for ${id} ###\n` : "";
const firstLine = `${title}HTTP/1.1 ${response.status} ${response.statusText}`;
return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`;
} catch (e) {
return `Error formatting response: ${e}`;
}
}
class MockServerPool {
private servers: MockServer[] = [];
public createServer(options?: Partial<MockServerOptions>): MockServer {
const baseUrl = options?.baseUrl || randomBaseUrl();
const server = new MockServer({ baseUrl, server: mswServer });
this.servers.push(server);
return server;
}
public getServers(): MockServer[] {
return [...this.servers];
}
public listen(): void {
const onUnhandledRequest = process.env.LOG_LEVEL === "debug" ? "warn" : "bypass";
mswServer.listen({ onUnhandledRequest });
if (process.env.LOG_LEVEL === "debug") {
mswServer.events.on("request:start", async ({ request, requestId }) => {
const formattedRequest = await formatHttpRequest(request, requestId);
console.debug(`request:start\n${formattedRequest}`);
});
mswServer.events.on("request:unhandled", async ({ request, requestId }) => {
const formattedRequest = await formatHttpRequest(request, requestId);
console.debug(`request:unhandled\n${formattedRequest}`);
});
mswServer.events.on("response:mocked", async ({ request, response, requestId }) => {
const formattedResponse = await formatHttpResponse(response, requestId);
console.debug(`response:mocked\n${formattedResponse}`);
});
}
}
public close(): void {
this.servers = [];
mswServer.close();
}
}
export const mockServerPool = new MockServerPool();

View File

@@ -0,0 +1,215 @@
import { type DefaultBodyType, type HttpHandler, HttpResponse, type HttpResponseResolver, http } from "msw";
import { url } from "../../src/core";
import { toJson } from "../../src/core/json";
import { withHeaders } from "./withHeaders";
import { withJson } from "./withJson";
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
interface MethodStage {
baseUrl(baseUrl: string): MethodStage;
all(path: string): RequestHeadersStage;
get(path: string): RequestHeadersStage;
post(path: string): RequestHeadersStage;
put(path: string): RequestHeadersStage;
delete(path: string): RequestHeadersStage;
patch(path: string): RequestHeadersStage;
options(path: string): RequestHeadersStage;
head(path: string): RequestHeadersStage;
}
interface RequestHeadersStage extends RequestBodyStage, ResponseStage {
header(name: string, value: string): RequestHeadersStage;
headers(headers: Record<string, string>): RequestBodyStage;
}
interface RequestBodyStage extends ResponseStage {
jsonBody(body: unknown): ResponseStage;
}
interface ResponseStage {
respondWith(): ResponseStatusStage;
}
interface ResponseStatusStage {
statusCode(statusCode: number): ResponseHeaderStage;
}
interface ResponseHeaderStage extends ResponseBodyStage, BuildStage {
header(name: string, value: string): ResponseHeaderStage;
headers(headers: Record<string, string>): ResponseHeaderStage;
}
interface ResponseBodyStage {
jsonBody(body: unknown): BuildStage;
}
interface BuildStage {
build(): HttpHandler;
}
export interface HttpHandlerBuilderOptions {
onBuild?: (handler: HttpHandler) => void;
once?: boolean;
}
class RequestBuilder implements MethodStage, RequestHeadersStage, RequestBodyStage, ResponseStage {
private method: HttpMethod = "get";
private _baseUrl: string = "";
private path: string = "/";
private readonly predicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[] = [];
private readonly handlerOptions?: HttpHandlerBuilderOptions;
constructor(options?: HttpHandlerBuilderOptions) {
this.handlerOptions = options;
}
baseUrl(baseUrl: string): MethodStage {
this._baseUrl = baseUrl;
return this;
}
all(path: string): RequestHeadersStage {
this.method = "all";
this.path = path;
return this;
}
get(path: string): RequestHeadersStage {
this.method = "get";
this.path = path;
return this;
}
post(path: string): RequestHeadersStage {
this.method = "post";
this.path = path;
return this;
}
put(path: string): RequestHeadersStage {
this.method = "put";
this.path = path;
return this;
}
delete(path: string): RequestHeadersStage {
this.method = "delete";
this.path = path;
return this;
}
patch(path: string): RequestHeadersStage {
this.method = "patch";
this.path = path;
return this;
}
options(path: string): RequestHeadersStage {
this.method = "options";
this.path = path;
return this;
}
head(path: string): RequestHeadersStage {
this.method = "head";
this.path = path;
return this;
}
header(name: string, value: string): RequestHeadersStage {
this.predicates.push((resolver) => withHeaders({ [name]: value }, resolver));
return this;
}
headers(headers: Record<string, string>): RequestBodyStage {
this.predicates.push((resolver) => withHeaders(headers, resolver));
return this;
}
jsonBody(body: unknown): ResponseStage {
if (body === undefined) {
throw new Error("Undefined is not valid JSON. Do not call jsonBody if you want an empty body.");
}
this.predicates.push((resolver) => withJson(body, resolver));
return this;
}
respondWith(): ResponseStatusStage {
return new ResponseBuilder(this.method, this.buildUrl(), this.predicates, this.handlerOptions);
}
private buildUrl(): string {
return url.join(this._baseUrl, this.path);
}
}
class ResponseBuilder implements ResponseStatusStage, ResponseHeaderStage, ResponseBodyStage, BuildStage {
private readonly method: HttpMethod;
private readonly url: string;
private readonly requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[];
private readonly handlerOptions?: HttpHandlerBuilderOptions;
private responseStatusCode: number = 200;
private responseHeaders: Record<string, string> = {};
private responseBody: DefaultBodyType = undefined;
constructor(
method: HttpMethod,
url: string,
requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[],
options?: HttpHandlerBuilderOptions,
) {
this.method = method;
this.url = url;
this.requestPredicates = requestPredicates;
this.handlerOptions = options;
}
public statusCode(code: number): ResponseHeaderStage {
this.responseStatusCode = code;
return this;
}
public header(name: string, value: string): ResponseHeaderStage {
this.responseHeaders[name] = value;
return this;
}
public headers(headers: Record<string, string>): ResponseHeaderStage {
this.responseHeaders = { ...this.responseHeaders, ...headers };
return this;
}
public jsonBody(body: unknown): BuildStage {
if (body === undefined) {
throw new Error("Undefined is not valid JSON. Do not call jsonBody if you expect an empty body.");
}
this.responseBody = toJson(body);
return this;
}
public build(): HttpHandler {
const responseResolver: HttpResponseResolver = () => {
const response = new HttpResponse(this.responseBody, {
status: this.responseStatusCode,
headers: this.responseHeaders,
});
// if no Content-Type header is set, delete the default text content type that is set
if (Object.keys(this.responseHeaders).some((key) => key.toLowerCase() === "content-type") === false) {
response.headers.delete("Content-Type");
}
return response;
};
const finalResolver = this.requestPredicates.reduceRight((acc, predicate) => predicate(acc), responseResolver);
const handler = http[this.method](this.url, finalResolver, this.handlerOptions);
this.handlerOptions?.onBuild?.(handler);
return handler;
}
}
export function mockEndpointBuilder(options?: HttpHandlerBuilderOptions): MethodStage {
return new RequestBuilder(options);
}

View File

@@ -0,0 +1,4 @@
export function randomBaseUrl(): string {
const randomString = Math.random().toString(36).substring(2, 15);
return `http://${randomString}.localhost`;
}

View File

@@ -0,0 +1,10 @@
import { afterAll, beforeAll } from "vitest";
import { mockServerPool } from "./MockServerPool";
beforeAll(() => {
mockServerPool.listen();
});
afterAll(() => {
mockServerPool.close();
});

View File

@@ -0,0 +1,70 @@
import { type HttpResponseResolver, passthrough } from "msw";
/**
* Creates a request matcher that validates if request headers match specified criteria
* @param expectedHeaders - Headers to match against
* @param resolver - Response resolver to execute if headers match
*/
export function withHeaders(
expectedHeaders: Record<string, string | RegExp | ((value: string) => boolean)>,
resolver: HttpResponseResolver,
): HttpResponseResolver {
return (args) => {
const { request } = args;
const { headers } = request;
const mismatches: Record<
string,
{ actual: string | null; expected: string | RegExp | ((value: string) => boolean) }
> = {};
for (const [key, expectedValue] of Object.entries(expectedHeaders)) {
const actualValue = headers.get(key);
if (actualValue === null) {
mismatches[key] = { actual: null, expected: expectedValue };
continue;
}
if (typeof expectedValue === "function") {
if (!expectedValue(actualValue)) {
mismatches[key] = { actual: actualValue, expected: expectedValue };
}
} else if (expectedValue instanceof RegExp) {
if (!expectedValue.test(actualValue)) {
mismatches[key] = { actual: actualValue, expected: expectedValue };
}
} else if (expectedValue !== actualValue) {
mismatches[key] = { actual: actualValue, expected: expectedValue };
}
}
if (Object.keys(mismatches).length > 0) {
const formattedMismatches = formatHeaderMismatches(mismatches);
console.error("Header mismatch:", formattedMismatches);
return passthrough();
}
return resolver(args);
};
}
function formatHeaderMismatches(
mismatches: Record<string, { actual: string | null; expected: string | RegExp | ((value: string) => boolean) }>,
): Record<string, { actual: string | null; expected: string }> {
const formatted: Record<string, { actual: string | null; expected: string }> = {};
for (const [key, { actual, expected }] of Object.entries(mismatches)) {
formatted[key] = {
actual,
expected:
expected instanceof RegExp
? expected.toString()
: typeof expected === "function"
? "[Function]"
: expected,
};
}
return formatted;
}

View File

@@ -0,0 +1,158 @@
import { type HttpResponseResolver, passthrough } from "msw";
import { fromJson, toJson } from "../../src/core/json";
/**
* Creates a request matcher that validates if the request JSON body exactly matches the expected object
* @param expectedBody - The exact body object to match against
* @param resolver - Response resolver to execute if body matches
*/
export function withJson(expectedBody: unknown, resolver: HttpResponseResolver): HttpResponseResolver {
return async (args) => {
const { request } = args;
let clonedRequest: Request;
let bodyText: string | undefined;
let actualBody: unknown;
try {
clonedRequest = request.clone();
bodyText = await clonedRequest.text();
if (bodyText === "") {
console.error("Request body is empty, expected a JSON object.");
return passthrough();
}
actualBody = fromJson(bodyText);
} catch (error) {
console.error(`Error processing request body:\n\tError: ${error}\n\tBody: ${bodyText}`);
return passthrough();
}
const mismatches = findMismatches(actualBody, expectedBody);
if (Object.keys(mismatches).filter((key) => !key.startsWith("pagination.")).length > 0) {
console.error("JSON body mismatch:", toJson(mismatches, undefined, 2));
return passthrough();
}
return resolver(args);
};
}
function findMismatches(actual: any, expected: any): Record<string, { actual: any; expected: any }> {
const mismatches: Record<string, { actual: any; expected: any }> = {};
if (typeof actual !== typeof expected) {
if (areEquivalent(actual, expected)) {
return {};
}
return { value: { actual, expected } };
}
if (typeof actual !== "object" || actual === null || expected === null) {
if (actual !== expected) {
if (areEquivalent(actual, expected)) {
return {};
}
return { value: { actual, expected } };
}
return {};
}
if (Array.isArray(actual) && Array.isArray(expected)) {
if (actual.length !== expected.length) {
return { length: { actual: actual.length, expected: expected.length } };
}
const arrayMismatches: Record<string, { actual: any; expected: any }> = {};
for (let i = 0; i < actual.length; i++) {
const itemMismatches = findMismatches(actual[i], expected[i]);
if (Object.keys(itemMismatches).length > 0) {
for (const [mismatchKey, mismatchValue] of Object.entries(itemMismatches)) {
arrayMismatches[`[${i}]${mismatchKey === "value" ? "" : `.${mismatchKey}`}`] = mismatchValue;
}
}
}
return arrayMismatches;
}
const actualKeys = Object.keys(actual);
const expectedKeys = Object.keys(expected);
const allKeys = new Set([...actualKeys, ...expectedKeys]);
for (const key of allKeys) {
if (!expectedKeys.includes(key)) {
if (actual[key] === undefined) {
continue; // Skip undefined values in actual
}
mismatches[key] = { actual: actual[key], expected: undefined };
} else if (!actualKeys.includes(key)) {
if (expected[key] === undefined) {
continue; // Skip undefined values in expected
}
mismatches[key] = { actual: undefined, expected: expected[key] };
} else if (
typeof actual[key] === "object" &&
actual[key] !== null &&
typeof expected[key] === "object" &&
expected[key] !== null
) {
const nestedMismatches = findMismatches(actual[key], expected[key]);
if (Object.keys(nestedMismatches).length > 0) {
for (const [nestedKey, nestedValue] of Object.entries(nestedMismatches)) {
mismatches[`${key}${nestedKey === "value" ? "" : `.${nestedKey}`}`] = nestedValue;
}
}
} else if (actual[key] !== expected[key]) {
if (areEquivalent(actual[key], expected[key])) {
continue;
}
mismatches[key] = { actual: actual[key], expected: expected[key] };
}
}
return mismatches;
}
function areEquivalent(actual: unknown, expected: unknown): boolean {
if (actual === expected) {
return true;
}
if (isEquivalentBigInt(actual, expected)) {
return true;
}
if (isEquivalentDatetime(actual, expected)) {
return true;
}
return false;
}
function isEquivalentBigInt(actual: unknown, expected: unknown) {
if (typeof actual === "number") {
actual = BigInt(actual);
}
if (typeof expected === "number") {
expected = BigInt(expected);
}
if (typeof actual === "bigint" && typeof expected === "bigint") {
return actual === expected;
}
return false;
}
function isEquivalentDatetime(str1: unknown, str2: unknown): boolean {
if (typeof str1 !== "string" || typeof str2 !== "string") {
return false;
}
const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
if (!isoDatePattern.test(str1) || !isoDatePattern.test(str2)) {
return false;
}
try {
const date1 = new Date(str1).getTime();
const date2 = new Date(str2).getTime();
return date1 === date2;
} catch {
return false;
}
}