Skip to content

Commit

Permalink
feat: Add next-trpc-openapi (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
kdy1 committed Apr 14, 2024
1 parent 21b7b0d commit 868dbfe
Show file tree
Hide file tree
Showing 15 changed files with 1,192 additions and 35 deletions.
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: dudykr
4 changes: 3 additions & 1 deletion packages/dudykr-utils/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
"skipLibCheck": true,
"isolatedModules": true,
"baseUrl": "."
}
}
3 changes: 3 additions & 0 deletions packages/next-trpc-openapi/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.js
*.d.ts
*.map
Empty file.
7 changes: 7 additions & 0 deletions packages/next-trpc-openapi/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# next-trpc-openapi

## 0.1.1

### Patch Changes

- 5181ed6: Initial release
105 changes: 105 additions & 0 deletions packages/next-trpc-openapi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# next-trpc-openapi

Fork of [trpc-openapi](https://github.com/jlalmes/trpc-openapi) that works with Next.js app router.

# Usage

Usage is similar to [trpc-openapi](https://github.com/jlalmes/trpc-openapi#usage).

## 1. Install `trpc-openapi` and `next-trpc-openapi`.

```bash
npm i next-trpc-openapi
# or
yarn add next-trpc-openapi
# or
pnpm i next-trpc-openapi
```

## 2. Add OpenApiMeta to your tRPC instance.

```ts
import { initTRPC } from "@trpc/server";
import { OpenApiMeta } from "trpc-openapi";

const t = initTRPC.meta<OpenApiMeta>().create(); /* 👈 */
```

## 3. Enable openapi support for a procedure.

```ts
export const appRouter = t.router({
sayHello: t.procedure
.meta({ /* 👉 */ openapi: { method: 'GET', path: '/say-hello' } })
.input(z.object({ name: z.string() }))
.output(z.object({ greeting: z.string() }))
.query(({ input }) => {
return { greeting: `Hello ${input.name}!` };
});
});
```

## 4. Generate an OpenAPI document.

```ts
import { generateOpenApiDocument } from "trpc-openapi";

import { appRouter } from "../appRouter";

/* 👇 */
export const openApiDocument = generateOpenApiDocument(appRouter, {
title: "tRPC OpenAPI",
version: "1.0.0",
baseUrl: "http://localhost:3000",
});
```

## 5. Add an trpc handler to your Next.js app.

`app/api/[trpc]/route.ts`:

```tsx
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { NextResponse } from "next/server";

const trpcApiRouteHandler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api",
req,
// Your trpc router
router: appRouter,
// Your trpc createContext
createContext,
});

export {
trpcApiRouteHandler as DELETE,
trpcApiRouteHandler as GET,
trpcApiRouteHandler as HEAD,
trpcApiRouteHandler as PATCH,
trpcApiRouteHandler as POST,
trpcApiRouteHandler as PUT,
};
```

## 6. Add an trpc-openapi handler to your Next.js app.

`app/api/[...trpc]/route.ts`:

```ts
import { NextApiRequest, NextApiResponse } from "next";
import { createOpenApiNextAppHandler } from "next-trpc-openapi";

const handler = createOpenApiNextAppHandler({
// Your trpc router
router: appRouter,
// Your trpc createContext
createContext,
});

export default handler;
```

## License

Apache-2.0
45 changes: 45 additions & 0 deletions packages/next-trpc-openapi/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { TRPCError } from "@trpc/server";

export const TRPC_ERROR_CODE_HTTP_STATUS: Record<TRPCError["code"], number> = {
PARSE_ERROR: 400,
BAD_REQUEST: 400,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
TIMEOUT: 408,
CONFLICT: 409,
CLIENT_CLOSED_REQUEST: 499,
PRECONDITION_FAILED: 412,
PAYLOAD_TOO_LARGE: 413,
METHOD_NOT_SUPPORTED: 405,
TOO_MANY_REQUESTS: 429,
UNPROCESSABLE_CONTENT: 422,
NOT_IMPLEMENTED: 500,
};

export function getErrorFromUnknown(cause: unknown): TRPCError {
if (cause instanceof Error && cause.name === "TRPCError") {
return cause as TRPCError;
}

let errorCause: Error | undefined = undefined;
let stack: string | undefined = undefined;

if (cause instanceof Error) {
errorCause = cause;
stack = cause.stack;
}

const error = new TRPCError({
message: "Internal server error",
code: "INTERNAL_SERVER_ERROR",
cause: errorCause,
});

if (stack) {
error.stack = stack;
}

return error;
}
205 changes: 205 additions & 0 deletions packages/next-trpc-openapi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { AnyProcedure, TRPCError } from "@trpc/server";
import { TRPC_ERROR_CODE_HTTP_STATUS, getErrorFromUnknown } from "errors";
import { NextRequest, NextResponse } from "next/server";
import { getInputOutputParsers } from "procedures";
import { CreateOpenApiNodeHttpHandlerOptions } from "trpc-openapi/dist/adapters/node-http/core";
import {
OpenApiErrorResponse,
OpenApiMethod,
OpenApiRouter,
} from "trpc-openapi/dist/types";
import {
instanceofZodTypeCoercible,
instanceofZodTypeLikeVoid,
instanceofZodTypeObject,
unwrapZodType,
zodSupportsCoerce,
} from "trpc-openapi/dist/utils/zod";
import { acceptsRequestBody } from "utils/methods";
import { normalizePath } from "utils/paths";
import { createProcedureCache } from "utils/procedures";
import { Context, createContext } from "vm";
import { ZodError, z } from "zod";

export type CreateOpenApiNextAppHandlerOptions<TRouter extends OpenApiRouter> =
Omit<
CreateOpenApiNodeHttpHandlerOptions<TRouter, NextRequest, NextResponse>,
"maxBodySize"
>;

export function createOpenApiNextAppHandler<TRouter extends OpenApiRouter>(
opts: CreateOpenApiNextAppHandlerOptions<TRouter>
) {
const { router, responseMeta, onError } = opts;

const getProcedure = createProcedureCache(router);

const handler = async (req: NextRequest): Promise<NextResponse<any>> => {
const method = req.method! as OpenApiMethod | "HEAD";
const reqUrl = req.url;
const url = new URL(
reqUrl.startsWith("/") ? `http://127.0.0.1${reqUrl}` : reqUrl
);
const path = normalizePath(url.pathname);
const { procedure, pathInput } =
getProcedure(method as OpenApiMethod, path) ?? {};

const resHeaders = new Headers();

let input: any = undefined;
let ctx: Context | undefined = undefined;
let data: any = undefined;

try {
if (!procedure) {
// Can be used for warmup
if (method === "HEAD") {
return NextResponse.json({}, { status: 204 });
}

throw new TRPCError({
code: "NOT_FOUND",
message: `Route not found for ${method} ${path}`,
});
}

const useBody = acceptsRequestBody(method as OpenApiMethod);
const schema = getInputOutputParsers(procedure.procedure)
.inputParser as z.ZodTypeAny;
const unwrappedSchema = unwrapZodType(schema, true);

// input should stay undefined if z.void()
if (!instanceofZodTypeLikeVoid(unwrappedSchema)) {
input = {
...(useBody
? await req.json()
: queryParamsToMap(req.nextUrl.searchParams)),
...pathInput,
};
}

// if supported, coerce all string values to correct types
if (zodSupportsCoerce) {
if (instanceofZodTypeObject(unwrappedSchema)) {
Object.values(unwrappedSchema.shape).forEach((shapeSchema) => {
const unwrappedShapeSchema = unwrapZodType(shapeSchema, false);
if (instanceofZodTypeCoercible(unwrappedShapeSchema)) {
unwrappedShapeSchema._def.coerce = true;
}
});
}
}

ctx = await createContext?.({ req, resHeaders });
const caller = router.createCaller(ctx);

const segments = procedure.path.split(".");
const procedureFn = segments.reduce(
(acc, curr) => acc[curr],
caller as any
) as AnyProcedure;

data = await procedureFn(input);

const meta = responseMeta?.({
type: procedure.type,
paths: [procedure.path],
ctx,
data: [data],
errors: [],
});

const statusCode = meta?.status ?? 200;
const headers = meta?.headers ?? {};

for (const [key, value] of Object.entries(headers)) {
if (typeof value !== "undefined") {
if (Array.isArray(value)) {
value.forEach((v) => resHeaders.append(key, v));
} else if (typeof value === "string") {
resHeaders.set(key, value);
} else {
throw new Error(`Invalid header value for key ${key}`);
}
}
}

return NextResponse.json(data, {
status: statusCode,
headers: resHeaders,
});
} catch (cause) {
const error = getErrorFromUnknown(cause);

onError?.({
error,
type: procedure?.type ?? "unknown",
path: procedure?.path,
input,
ctx,
req,
});

const meta = responseMeta?.({
type: procedure?.type ?? "unknown",
paths: procedure?.path ? [procedure?.path] : undefined,
ctx,
data: [data],
errors: [error],
});

const errorShape = router.getErrorShape({
error,
type: procedure?.type ?? "unknown",
path: procedure?.path,
input,
ctx,
});

const isInputValidationError =
error.code === "BAD_REQUEST" &&
error.cause instanceof Error &&
error.cause.name === "ZodError";

const statusCode =
meta?.status ?? TRPC_ERROR_CODE_HTTP_STATUS[error.code] ?? 500;
const headers = meta?.headers ?? {};
const body: OpenApiErrorResponse = {
message: isInputValidationError
? "Input validation failed"
: errorShape?.message ?? error.message ?? "An error occurred",
code: error.code,
issues: isInputValidationError
? (error.cause as ZodError).errors
: undefined,
};

for (const [key, value] of Object.entries(headers)) {
if (typeof value !== "undefined") {
if (Array.isArray(value)) {
value.forEach((v) => resHeaders.append(key, v));
} else if (typeof value === "string") {
resHeaders.set(key, value);
} else {
throw new Error(`Invalid header value for key ${key}`);
}
}
}

return NextResponse.json(body, {
status: statusCode,
headers: resHeaders,
});
}
};
function queryParamsToMap(q: URLSearchParams): object {
const entries = q.entries();
const result: Record<string, string> = {};
for (const [key, value] of entries) {
result[key] = value;
}
return result;
}

return handler;
}
Loading

0 comments on commit 868dbfe

Please sign in to comment.