feat: implement openapi (#482)

This commit is contained in:
Manuel
2024-08-25 18:03:32 +02:00
committed by GitHub
parent a3520e4dfd
commit f0cd45c813
17 changed files with 14986 additions and 637 deletions

View File

@@ -67,6 +67,7 @@
"react-simple-code-editor": "^0.14.1",
"sass": "^1.77.8",
"superjson": "2.2.1",
"swagger-ui-react": "^5.17.7",
"use-deep-compare-effect": "^1.8.1"
},
"devDependencies": {
@@ -78,6 +79,7 @@
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^8.2.2",
"eslint": "^9.9.1",
"node-loader": "^2.0.0",

View File

@@ -84,6 +84,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBrandDocker,
href: "/manage/tools/docker",
},
{
label: t("items.tools.items.api"),
icon: IconPlug,
href: "/manage/tools/api",
},
{
label: t("items.tools.items.logs"),
icon: IconLogs,

View 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} />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
.swagger-ui .info {
margin: 0 !important;
margin-bottom: 20px !important;
}

File diff suppressed because it is too large Load Diff

View 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 };

View 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)));
}

View File

@@ -43,5 +43,10 @@
"vite-tsconfig-paths": "^5.0.1",
"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"
}
}
}

View File

@@ -37,6 +37,7 @@
"@trpc/server": "next",
"dockerode": "^4.0.2",
"superjson": "2.2.1",
"trpc-swagger": "^1.2.3",
"next": "^14.2.6",
"react": "^18.3.1"
},

View File

@@ -1,5 +1,6 @@
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { openApiDocument } from "./open-api";
import type { AppRouter } from "./root";
import { appRouter } from "./root";
import { createCallerFactory, createTRPCContext } from "./trpc";
@@ -29,5 +30,5 @@ type RouterInputs = inferRouterInputs<AppRouter>;
**/
type RouterOutputs = inferRouterOutputs<AppRouter>;
export { createTRPCContext, appRouter, createCaller };
export { createTRPCContext, appRouter, createCaller, openApiDocument };
export type { AppRouter, RouterInputs, RouterOutputs };

View 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",
});

View File

@@ -69,12 +69,16 @@ export const userRouter = createTRPCRouter({
// Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere);
}),
create: publicProcedure.input(validation.user.create).mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
create: publicProcedure
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"] } })
.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
.input(
z.object({
@@ -126,20 +130,33 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
getAll: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
provider: true,
},
});
}),
selectable: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.users.findMany({
getAll: publicProcedure
.input(z.void())
.output(
z.array(
z.object({
id: z.string(),
name: z.string().nullable(),
email: z.string().nullable(),
emailVerified: z.date().nullable(),
image: z.string().nullable(),
}),
),
)
.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: {
id: true,
name: true,

View File

@@ -8,6 +8,7 @@
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { OpenApiMeta } from "trpc-swagger";
import type { Session } from "@homarr/auth";
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
* transformer
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter: ({ shape, error }) => ({
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
error: error.cause instanceof FlattenError ? error.cause.flatten() : null,
},
}),
});
const t = initTRPC
.context<typeof createTRPCContext>()
.meta<OpenApiMeta>()
.create({
transformer: superjson,
errorFormatter: ({ shape, error }) => ({
...shape,
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

View File

@@ -1414,6 +1414,7 @@ export default {
items: {
docker: "Docker",
logs: "Logs",
api: "API",
tasks: "Tasks",
},
},

File diff suppressed because it is too large Load Diff

2299
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff