Skip to content

Commit

Permalink
feat: add initial openapi-msw implementation (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
christoph-fricke committed Oct 18, 2023
1 parent ff4e55d commit b74d819
Show file tree
Hide file tree
Showing 14 changed files with 1,481 additions and 101 deletions.
13 changes: 12 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,16 @@
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic"
],
"rules": {}
"rules": {
"@typescript-eslint/no-empty-interface": [
"error",
{
// Often types are computed and expanded in Intellisense previews in editors,
// which can lead to verbose and heard to understand type signatures.
// Interface keep their name in previews, which can be used to clarify
// previews by using interfaces that just extend a type.
"allowSingleExtends": true
}
]
}
}
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"cSpell.words": ["openapi"],
"testing.automaticallyOpenPeekView": "never"
}
8 changes: 8 additions & 0 deletions exports/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { createOpenApiHttp } from "../src/openapi-http.js";
export type { HttpOptions, OpenApiHttpHandlers } from "../src/openapi-http.js";

export type {
AnyApiSpec,
HttpHandlerFactory,
HttpMethod,
} from "../src/type-helpers.js";
1,280 changes: 1,195 additions & 85 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
],
"exports": {
".": {
"types": "./dist/main.d.ts",
"import": "./dist/main.js"
"types": "./dist/exports/main.d.ts",
"import": "./dist/exports/main.js"
}
},
"scripts": {
Expand All @@ -30,6 +30,12 @@
"*": "prettier --ignore-unknown --write",
"*.{ts,js,mjs}": "eslint --fix"
},
"dependencies": {
"openapi-typescript-helpers": "^0.0.4"
},
"peerDependencies": {
"msw": "0.0.0-fetch.rc-23"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
Expand Down
8 changes: 0 additions & 8 deletions src/main.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/main.ts

This file was deleted.

67 changes: 67 additions & 0 deletions src/openapi-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it, vi } from "vitest";
import { http as mswHttp } from "msw";
import { createOpenApiHttp } from "./openapi-http.js";
import type { HttpMethod } from "./type-helpers.js";

const methods: HttpMethod[] = [
"get",
"put",
"post",
"delete",
"options",
"head",
"patch",
];

describe(createOpenApiHttp, () => {
it("should create an http handlers object", () => {
const http = createOpenApiHttp();

expect(http).toBeTypeOf("object");
for (const method of methods) {
expect(http[method]).toBeTypeOf("function");
}
});

it("should include the original MSW methods in its return type", () => {
const http = createOpenApiHttp();

expect(http.untyped).toBe(mswHttp);
});
});

describe.each(methods)("openapi %s http handlers", (method) => {
it("should forward its arguments to MSW", () => {
const spy = vi.spyOn(mswHttp, method);
const resolver = vi.fn();

const http = createOpenApiHttp<any>();
http[method]("/test", resolver, { once: false });

expect(spy).toHaveBeenCalledOnce();
expect(spy).toHaveBeenCalledWith("/test", resolver, { once: false });
});

it("should convert openapi paths to MSW compatible paths", () => {
const spy = vi.spyOn(mswHttp, method);
const resolver = vi.fn();

const http = createOpenApiHttp<any>();
http[method]("/test/{id}", resolver);

expect(spy).toHaveBeenCalledOnce();
expect(spy).toHaveBeenCalledWith("/test/:id", resolver, undefined);
});

it("should prepend a configured baseUrl to the path for MSW", () => {
const spy = vi.spyOn(mswHttp, method);
const resolver = vi.fn();

const http = createOpenApiHttp<any>({ baseUrl: "*/api/rest" });
http[method]("/test", resolver);

expect(spy).toHaveBeenCalledOnce();
expect(spy).toHaveBeenCalledWith("*/api/rest/test", resolver, undefined);
});
});
43 changes: 43 additions & 0 deletions src/openapi-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { http as mswHttp } from "msw";
import { convertToColonPath } from "./path-mapping.js";
import type {
AnyApiSpec,
HttpHandlerFactory,
HttpMethod,
} from "./type-helpers.js";

export type OpenApiHttpHandlers<ApiSpec extends AnyApiSpec> = {
[Method in HttpMethod]: HttpHandlerFactory<ApiSpec, Method>;
} & { untyped: typeof mswHttp };

export interface HttpOptions {
baseUrl?: string;
}

export function createOpenApiHttp<ApiSpec extends AnyApiSpec>(
options?: HttpOptions,
): OpenApiHttpHandlers<ApiSpec> {
return {
get: createHttpWrapper<ApiSpec, "get">("get", options),
put: createHttpWrapper<ApiSpec, "put">("put", options),
post: createHttpWrapper<ApiSpec, "post">("post", options),
delete: createHttpWrapper<ApiSpec, "delete">("delete", options),
options: createHttpWrapper<ApiSpec, "options">("options", options),
head: createHttpWrapper<ApiSpec, "head">("head", options),
patch: createHttpWrapper<ApiSpec, "patch">("patch", options),
untyped: mswHttp,
};
}

function createHttpWrapper<
ApiSpec extends AnyApiSpec,
Method extends HttpMethod,
>(
method: Method,
httpOptions?: HttpOptions,
): HttpHandlerFactory<ApiSpec, Method> {
return (path, resolver, options) => {
const mswPath = convertToColonPath(path as string, httpOptions?.baseUrl);
return mswHttp[method](mswPath, resolver, options);
};
}
30 changes: 30 additions & 0 deletions src/path-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { convertToColonPath } from "./path-mapping.js";
import { describe, it, expect } from "vitest";

describe(convertToColonPath, () => {
it("should leave paths with no path fragments untouched", () => {
const result = convertToColonPath("/users");

expect(result).toBe("/users");
});

it("should convert a path fragment to colon convention", () => {
const result = convertToColonPath("/users/{id}");

expect(result).toBe("/users/:id");
});

it("should convert all fragment in a path to colon convention", () => {
const result = convertToColonPath("/users/{userId}/posts/{postIds}");

expect(result).toBe("/users/:userId/posts/:postIds");
});

it("should append baseUrl to the path when provided", () => {
const noBaseUrl = convertToColonPath("/users");
const withBaseUrl = convertToColonPath("/users", "https://localhost:3000");

expect(noBaseUrl).toBe("/users");
expect(withBaseUrl).toBe("https://localhost:3000/users");
});
});
12 changes: 12 additions & 0 deletions src/path-mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Converts a OpenAPI path fragment convention to the colon convention that is
* commonly used in Node.js and also MSW.
*
* @example /users/{id} --> /users/:id
*/
export function convertToColonPath(path: string, baseUrl?: string): string {
const resolvedPath = path.replaceAll("{", ":").replaceAll("}", "");
if (!baseUrl) return resolvedPath;

return baseUrl + resolvedPath;
}
100 changes: 100 additions & 0 deletions src/type-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type {
FilterKeys,
MediaType,
OperationRequestBodyContent,
PathsWithMethod,
ResponseObjectMap,
SuccessResponse,
} from "openapi-typescript-helpers";
import type { http } from "msw";

/** Base type that any api spec should extend. */
export type AnyApiSpec = NonNullable<unknown>;

/** Intersection of HTTP methods that are supported by both OpenApi-TS and MSW. */
export type HttpMethod =
| "get"
| "put"
| "post"
| "delete"
| "options"
| "head"
| "patch";

/** Returns a union of all paths that exists in an api spec for a given method. */
export type PathsForMethod<
ApiSpec extends AnyApiSpec,
Method extends HttpMethod,
> = PathsWithMethod<ApiSpec, Method>;

/** Extract the params of a given path and method from an api spec. */
export type PathParams<
ApiSpec extends AnyApiSpec,
Path extends keyof ApiSpec,
Method extends HttpMethod,
> = Method extends keyof ApiSpec[Path]
? ApiSpec[Path][Method] extends {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameters: { path: any };
}
? Required<ApiSpec[Path][Method]["parameters"]["path"]>
: never
: never;

/** Extract the request body of a given path and method from an api spec. */
export type RequestBody<
ApiSpec extends AnyApiSpec,
Path extends keyof ApiSpec,
Method extends HttpMethod,
> = Method extends keyof ApiSpec[Path]
? OperationRequestBodyContent<ApiSpec[Path][Method]>
: never;

/** Extract the response body of a given path and method from an api spec. */
export type ResponseBody<
ApiSpec extends AnyApiSpec,
Path extends keyof ApiSpec,
Method extends HttpMethod,
> = Method extends keyof ApiSpec[Path]
? ApiSpec[Path][Method] extends {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
responses: any;
}
? FilterKeys<
SuccessResponse<ResponseObjectMap<ApiSpec[Path][Method]>>,
MediaType
>
: never
: never;

/** MSW http handler factory with type inference for provided api paths. */
export type HttpHandlerFactory<
ApiSpec extends AnyApiSpec,
Method extends HttpMethod,
> = <Path extends PathsForMethod<ApiSpec, Method>>(
path: Path,
resolver: ResponseResolver<ApiSpec, Path, Method>,
options?: RequestHandlerOptions,
) => ReturnType<typeof http.all>;

/** MSW handler options. */
export type RequestHandlerOptions = Required<Parameters<typeof http.all>[2]>;

/** MSW response resolver function that is made type-safe through an api spec. */
export interface ResponseResolver<
ApiSpec extends AnyApiSpec,
Path extends keyof ApiSpec,
Method extends HttpMethod,
> extends ResponseResolverType<ApiSpec, Path, Method> {}

type ResponseResolverType<
ApiSpec extends AnyApiSpec,
Path extends keyof ApiSpec,
Method extends HttpMethod,
> = Parameters<
typeof http.all<
PathParams<ApiSpec, Path, Method>,
RequestBody<ApiSpec, Path, Method>,
ResponseBody<ApiSpec, Path, Method>
>
>[1];
2 changes: 1 addition & 1 deletion tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src"],
"include": ["src", "exports"],
"exclude": ["**/*.test.*"]
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@
"skipDefaultLibCheck": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src", "exports"]
}

0 comments on commit b74d819

Please sign in to comment.