feat: implement openapi (#482)
This commit is contained in:
@@ -67,6 +67,7 @@
|
|||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.77.8",
|
"sass": "^1.77.8",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
|
"swagger-ui-react": "^5.17.7",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
"@types/prismjs": "^1.26.4",
|
"@types/prismjs": "^1.26.4",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.4",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.9.1",
|
||||||
"node-loader": "^2.0.0",
|
"node-loader": "^2.0.0",
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconBrandDocker,
|
icon: IconBrandDocker,
|
||||||
href: "/manage/tools/docker",
|
href: "/manage/tools/docker",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.api"),
|
||||||
|
icon: IconPlug,
|
||||||
|
href: "/manage/tools/api",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.logs"),
|
label: t("items.tools.items.logs"),
|
||||||
icon: IconLogs,
|
icon: IconLogs,
|
||||||
|
|||||||
28
apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx
Normal file
28
apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
|
||||||
|
import "./swagger-ui-dark.css";
|
||||||
|
import "./swagger-ui-overrides.css";
|
||||||
|
import "./swagger-ui.css";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import SwaggerUI from "swagger-ui-react";
|
||||||
|
|
||||||
|
import { openApiDocument } from "@homarr/api";
|
||||||
|
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getScopedI18n("management");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: createMetaTitle(t("metaTitle")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiPage() {
|
||||||
|
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
|
||||||
|
|
||||||
|
return <SwaggerUI spec={document} />;
|
||||||
|
}
|
||||||
1711
apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-dark.css
Normal file
1711
apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-dark.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
.swagger-ui .info {
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
}
|
||||||
9296
apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css
Normal file
9296
apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css
Normal file
File diff suppressed because it is too large
Load Diff
14
apps/nextjs/src/app/api/[...trpc]/route.ts
Normal file
14
apps/nextjs/src/app/api/[...trpc]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
|
||||||
|
|
||||||
|
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||||
|
|
||||||
|
const handler = (req: Request) => {
|
||||||
|
return createOpenApiFetchHandler({
|
||||||
|
req,
|
||||||
|
endpoint: "/",
|
||||||
|
router: appRouter,
|
||||||
|
createContext: () => createTRPCContext({ session: null, headers: req.headers }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
8
apps/nextjs/src/app/api/openapi/route.ts
Normal file
8
apps/nextjs/src/app/api/openapi/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { openApiDocument } from "@homarr/api";
|
||||||
|
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||||
|
|
||||||
|
export function GET(request: Request) {
|
||||||
|
return NextResponse.json(openApiDocument(extractBaseUrlFromHeaders(request.headers)));
|
||||||
|
}
|
||||||
@@ -43,5 +43,10 @@
|
|||||||
"vite-tsconfig-paths": "^5.0.1",
|
"vite-tsconfig-paths": "^5.0.1",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config",
|
||||||
|
"pnpm": {
|
||||||
|
"patchedDependencies": {
|
||||||
|
"trpc-swagger@1.2.6": "patches/trpc-swagger@1.2.6.patch"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
"dockerode": "^4.0.2",
|
"dockerode": "^4.0.2",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
|
"trpc-swagger": "^1.2.3",
|
||||||
"next": "^14.2.6",
|
"next": "^14.2.6",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||||
|
|
||||||
|
import { openApiDocument } from "./open-api";
|
||||||
import type { AppRouter } from "./root";
|
import type { AppRouter } from "./root";
|
||||||
import { appRouter } from "./root";
|
import { appRouter } from "./root";
|
||||||
import { createCallerFactory, createTRPCContext } from "./trpc";
|
import { createCallerFactory, createTRPCContext } from "./trpc";
|
||||||
@@ -29,5 +30,5 @@ type RouterInputs = inferRouterInputs<AppRouter>;
|
|||||||
**/
|
**/
|
||||||
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||||
|
|
||||||
export { createTRPCContext, appRouter, createCaller };
|
export { createTRPCContext, appRouter, createCaller, openApiDocument };
|
||||||
export type { AppRouter, RouterInputs, RouterOutputs };
|
export type { AppRouter, RouterInputs, RouterOutputs };
|
||||||
|
|||||||
11
packages/api/src/open-api.ts
Normal file
11
packages/api/src/open-api.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { generateOpenApiDocument } from "trpc-swagger";
|
||||||
|
|
||||||
|
import { appRouter } from "./root";
|
||||||
|
|
||||||
|
export const openApiDocument = (base: string) =>
|
||||||
|
generateOpenApiDocument(appRouter, {
|
||||||
|
title: "Homarr API documentation",
|
||||||
|
version: "1.0.0",
|
||||||
|
baseUrl: base,
|
||||||
|
docsUrl: "https://homarr.dev",
|
||||||
|
});
|
||||||
@@ -69,12 +69,16 @@ export const userRouter = createTRPCRouter({
|
|||||||
// Delete invite as it's used
|
// Delete invite as it's used
|
||||||
await ctx.db.delete(invites).where(inviteWhere);
|
await ctx.db.delete(invites).where(inviteWhere);
|
||||||
}),
|
}),
|
||||||
create: publicProcedure.input(validation.user.create).mutation(async ({ ctx, input }) => {
|
create: publicProcedure
|
||||||
throwIfCredentialsDisabled();
|
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"] } })
|
||||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
.input(validation.user.create)
|
||||||
|
.output(z.void())
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
throwIfCredentialsDisabled();
|
||||||
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
||||||
|
|
||||||
await createUserAsync(ctx.db, input);
|
await createUserAsync(ctx.db, input);
|
||||||
}),
|
}),
|
||||||
setProfileImage: protectedProcedure
|
setProfileImage: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -126,20 +130,33 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, input.userId));
|
.where(eq(users.id, input.userId));
|
||||||
}),
|
}),
|
||||||
getAll: publicProcedure.query(async ({ ctx }) => {
|
getAll: publicProcedure
|
||||||
return await ctx.db.query.users.findMany({
|
.input(z.void())
|
||||||
columns: {
|
.output(
|
||||||
id: true,
|
z.array(
|
||||||
name: true,
|
z.object({
|
||||||
email: true,
|
id: z.string(),
|
||||||
emailVerified: true,
|
name: z.string().nullable(),
|
||||||
image: true,
|
email: z.string().nullable(),
|
||||||
provider: true,
|
emailVerified: z.date().nullable(),
|
||||||
},
|
image: z.string().nullable(),
|
||||||
});
|
}),
|
||||||
}),
|
),
|
||||||
selectable: publicProcedure.query(async ({ ctx }) => {
|
)
|
||||||
return await ctx.db.query.users.findMany({
|
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"] } })
|
||||||
|
.query(({ ctx }) => {
|
||||||
|
return ctx.db.query.users.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
selectable: publicProcedure.query(({ ctx }) => {
|
||||||
|
return ctx.db.query.users.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import type { OpenApiMeta } from "trpc-swagger";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { FlattenError } from "@homarr/common";
|
import { FlattenError } from "@homarr/common";
|
||||||
@@ -46,17 +47,20 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
|
|||||||
* This is where the trpc api is initialized, connecting the context and
|
* This is where the trpc api is initialized, connecting the context and
|
||||||
* transformer
|
* transformer
|
||||||
*/
|
*/
|
||||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
const t = initTRPC
|
||||||
transformer: superjson,
|
.context<typeof createTRPCContext>()
|
||||||
errorFormatter: ({ shape, error }) => ({
|
.meta<OpenApiMeta>()
|
||||||
...shape,
|
.create({
|
||||||
data: {
|
transformer: superjson,
|
||||||
...shape.data,
|
errorFormatter: ({ shape, error }) => ({
|
||||||
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
...shape,
|
||||||
error: error.cause instanceof FlattenError ? error.cause.flatten() : null,
|
data: {
|
||||||
},
|
...shape.data,
|
||||||
}),
|
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||||
});
|
error: error.cause instanceof FlattenError ? error.cause.flatten() : null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a server-side caller
|
* Create a server-side caller
|
||||||
|
|||||||
@@ -1414,6 +1414,7 @@ export default {
|
|||||||
items: {
|
items: {
|
||||||
docker: "Docker",
|
docker: "Docker",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
|
api: "API",
|
||||||
tasks: "Tasks",
|
tasks: "Tasks",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
2152
patches/trpc-swagger@1.2.6.patch
Normal file
2152
patches/trpc-swagger@1.2.6.patch
Normal file
File diff suppressed because it is too large
Load Diff
2299
pnpm-lock.yaml
generated
2299
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user