-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
15 changed files
with
1,192 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
github: dudykr |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
*.js | ||
*.d.ts | ||
*.map |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.