Skip to content

Commit

Permalink
feature: add trpc openapi (#1818)
Browse files Browse the repository at this point in the history
  • Loading branch information
manuel-rw committed Jan 14, 2024
1 parent 33da630 commit c701f72
Show file tree
Hide file tree
Showing 18 changed files with 2,181 additions and 138 deletions.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"dotenv": "^16.3.1",
"drizzle-kit": "^0.19.13",
"drizzle-orm": "^0.28.6",
"drizzle-zod": "^0.5.1",
"fily-publish-gridstack": "^0.0.13",
"flag-icons": "^6.9.2",
"framer-motion": "^10.0.0",
Expand All @@ -97,6 +98,7 @@
"next": "13.4.12",
"next-auth": "^4.23.0",
"next-i18next": "^14.0.0",
"nextjs-cors": "^2.2.0",
"nzbget-api": "^0.0.3",
"prismjs": "^1.29.0",
"react": "^18.2.0",
Expand All @@ -105,6 +107,8 @@
"react-simple-code-editor": "^0.13.1",
"rss-parser": "^3.12.0",
"sabnzbd-api": "^1.5.0",
"swagger-ui-react": "^5.11.0",
"trpc-openapi": "^1.2.0",
"uuid": "^9.0.0",
"xml-js": "^1.6.11",
"xss": "^1.0.14",
Expand All @@ -122,6 +126,7 @@
"@types/node": "18.17.8",
"@types/prismjs": "^1.26.0",
"@types/react": "^18.2.11",
"@types/swagger-ui-react": "^4.18.3",
"@types/umami": "^0.1.4",
"@types/uuid": "^9.0.0",
"@types/video.js": "^7.3.51",
Expand Down
3 changes: 2 additions & 1 deletion public/locales/en/layout/manage.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"tools": {
"title": "Tools",
"items": {
"docker": "Docker"
"docker": "Docker",
"api": "API"
}
},
"about": {
Expand Down
27 changes: 13 additions & 14 deletions src/components/Manage/User/Edit/ManageUserRoles.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { ActionIcon, Badge, Box, Group, Title, Text, Tooltip, Button } from '@mantine/core';
import { Badge, Box, Button, Group, Text, Title } from '@mantine/core';
import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal';
import { IconUserDown, IconUserUp } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useSession } from 'next-auth/react';
import { createSelectSchema } from 'drizzle-zod';
import { users } from '~/server/db/schema';
import { z } from 'zod';

const userWithoutSecrets = createSelectSchema(users).omit({
password: true,
salt: true,
});

export const ManageUserRoles = ({ user }: {
user: {
image: string | null;
id: string;
name: string | null;
password: string | null;
email: string | null;
emailVerified: Date | null;
salt: string | null;
isAdmin: boolean;
isOwner: boolean;
}
user: z.infer<typeof userWithoutSecrets>
}) => {
const { t } = useTranslation(['manage/users/edit', 'manage/users']);
const { data: sessionData } = useSession();

return (
<Box maw={500}>
<Title order={3}>
Expand All @@ -33,7 +32,7 @@ export const ManageUserRoles = ({ user }: {

{user.isAdmin ? (
<Button
leftIcon={<IconUserDown size='1rem' />}
leftIcon={<IconUserDown size="1rem" />}
disabled={user.id === sessionData?.user?.id || user.isOwner}
onClick={() => {
openRoleChangeModal({
Expand All @@ -47,7 +46,7 @@ export const ManageUserRoles = ({ user }: {
</Button>
) : (
<Button
leftIcon={<IconUserUp size='1rem' />}
leftIcon={<IconUserUp size="1rem" />}
onClick={() => {
openRoleChangeModal({
name: user.name as string,
Expand Down
9 changes: 5 additions & 4 deletions src/components/layout/Templates/ManageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import {
Indicator,
NavLink,
Navbar,
Paper,
Text,
ThemeIcon,
useMantineTheme,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
Expand All @@ -21,10 +19,9 @@ import {
IconBrandGithub,
IconGitFork,
IconHome,
IconInfoCircle,
IconInfoSmall,
IconLayoutDashboard,
IconMailForward,
IconMailForward, IconPlug,
IconQuestionMark,
IconTool,
IconUser,
Expand Down Expand Up @@ -104,6 +101,10 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
icon: IconBrandDocker,
href: '/manage/tools/docker',
},
api: {
icon: IconPlug,
href: '/manage/tools/swagger'
}
},
},
help: {
Expand Down
22 changes: 22 additions & 0 deletions src/pages/api/[...trpc].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextApiRequest, NextApiResponse } from 'next';
import cors from 'nextjs-cors';
import { createOpenApiNextHandler } from 'trpc-openapi';
import { createTRPCContext } from '~/server/api/trpc';
import { rootRouter } from '~/server/api/root';
import Consola from 'consola';

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// Setup CORS
await cors(req, res);

// Handle incoming OpenAPI requests
return createOpenApiNextHandler({
router: rootRouter,
createContext: createTRPCContext,
onError({ error, path }) {
Consola.error(`tRPC OpenAPI error on ${path}: ${error}`);
}
})(req, res);
};

export default handler;
9 changes: 9 additions & 0 deletions src/pages/api/openapi.json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { openApiDocument } from '~/server/openai';

// Respond with our OpenAPI schema
const handler = (req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
};

export default handler;
93 changes: 93 additions & 0 deletions src/pages/manage/tools/swagger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { GetServerSidePropsContext, NextPage } from 'next';
import dynamic from 'next/dynamic';
import 'swagger-ui-react/swagger-ui.css';
import React, { useEffect } from 'react';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import Head from 'next/head';
import { ActionIcon, Button, Group, Text, TextInput, Title, Tooltip, useMantineTheme } from '@mantine/core';
import { IconCopy, IconLockAccess } from '@tabler/icons-react';
import { useClipboard, useDisclosure } from '@mantine/hooks';
import Cookies from 'cookies';
import { getServerAuthSession } from '~/server/auth';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';

const SwaggerUI = dynamic(() => import('swagger-ui-react'), { ssr: false });

const SwaggerApiPage = ({ authenticationToken }: { authenticationToken: string }) => {
const [accessTokenRevealed, { toggle: toggleAccessTokenReveal, close: hideAccessToken }] = useDisclosure(false);
const clipboard = useClipboard({ timeout: 2500 });

useEffect(() => {
if (clipboard.copied) {
return;
}

hideAccessToken();
}, [clipboard.copied]);

const theme = useMantineTheme();

return <ManageLayout>
<Head>
<title>API • Homarr</title>
</Head>

<Title mb={'md'}>API</Title>
<Text mb={'xl'}>Advanced users can use the API to interface with Homarr. The documentation is completely local,
interactive
and complies with the Open API standard. Any compatible client can import for easy usage.</Text>


<Group>
<Button onClick={toggleAccessTokenReveal} leftIcon={<IconLockAccess size={'1rem'} />} variant={'light'}>
Show your personal access token
</Button>
{accessTokenRevealed && (
<TextInput
rightSection={
<Tooltip opened={clipboard.copied} label={"Copied"}>
<ActionIcon
onClick={() => {
clipboard.copy(authenticationToken);
}}>
<IconCopy size={'1rem'} />
</ActionIcon>
</Tooltip>}
value={authenticationToken} />
)}
</Group>

<div data-color-scheme={theme.colorScheme} className={"open-api-container"}>
<SwaggerUI url="/api/openapi.json" />
</div>
</ManageLayout>;
};

export async function getServerSideProps(ctx: GetServerSidePropsContext) {

const session = await getServerAuthSession(ctx);
const result = checkForSessionOrAskForLogin(ctx, session, () => true);
if (result) {
return result;
}

// Create a cookies instance
const cookies = new Cookies(ctx.req, ctx.res);

const authenticationToken = cookies.get('next-auth.session-token');

return {
props: {
authenticationToken: authenticationToken,
...(await getServerSideTranslations(
['layout/manage', 'manage/index'],
ctx.locale,
ctx.req,
ctx.res,
)),
},
};
}

export default SwaggerApiPage;
2 changes: 1 addition & 1 deletion src/pages/manage/users/[userId]/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const EditPage = () => {

const router = useRouter();

const { isLoading, data } = api.user.details.useQuery({ userId: router.query.userId as string });
const { data } = api.user.details.useQuery({ userId: router.query.userId as string });

const metaTitle = `${t('metaTitle', {
username: data?.name,
Expand Down
10 changes: 8 additions & 2 deletions src/server/api/routers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ import * as https from 'https';

export const appRouter = createTRPCRouter({
ping: publicProcedure
.meta({ openapi: { method: 'GET', path: '/app/ping', tags: ['app'] } })
.input(
z.object({
id: z.string(),
configName: z.string(),
})
}),
)
.output(z.object({
status: z.number(),
statusText: z.string(),
state: z.string()
}))
.query(async ({ input }) => {
const config = getConfig(input.configName);
const app = config.apps.find((app) => app.id === input.id);
Expand Down Expand Up @@ -62,7 +68,7 @@ export const appRouter = createTRPCRouter({

if (error.code === 'ECONNABORTED') {
Consola.error(
`Ping timed out for app with id '${input.id}' in config '${input.configName}' -> url: ${app.url})`
`Ping timed out for app with id '${input.id}' in config '${input.configName}' -> url: ${app.url})`,
);
throw new TRPCError({
code: 'TIMEOUT',
Expand Down
63 changes: 40 additions & 23 deletions src/server/api/routers/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,43 @@ import { writeConfig } from '~/tools/config/writeConfig';
import { configNameSchema } from '~/validations/boards';

export const boardRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));

const defaultBoard = await getDefaultBoardAsync(ctx.session.user.id, 'default');

return await Promise.all(
files.map(async (file) => {
const name = file.replace('.json', '');
const config = await getFrontendConfig(name);

const countApps = config.apps.length;

return {
name: name,
allowGuests: config.settings.access.allowGuests,
countApps: countApps,
countWidgets: config.widgets.length,
countCategories: config.categories.length,
isDefaultForUser: name === defaultBoard,
};
}),
);
}),
all: protectedProcedure
.meta({ openapi: { method: 'GET', path: '/boards/all', tags: ['board'] } })
.input(z.void())
.output(z.array(z.object({
name: z.string(),
allowGuests: z.boolean(),
countApps: z.number().min(0),
countWidgets: z.number().min(0),
countCategories: z.number().min(0),
isDefaultForUser: z.boolean(),
})))
.query(async ({ ctx }) => {
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));

const defaultBoard = await getDefaultBoardAsync(ctx.session.user.id, 'default');

return await Promise.all(
files.map(async (file) => {
const name = file.replace('.json', '');
const config = await getFrontendConfig(name);

const countApps = config.apps.length;

return {
name: name,
allowGuests: config.settings.access.allowGuests,
countApps: countApps,
countWidgets: config.widgets.length,
countCategories: config.categories.length,
isDefaultForUser: name === defaultBoard,
};
}),
);
}),
addAppsForContainers: adminProcedure
.meta({ openapi: { method: 'POST', path: '/boards/add-apps', tags: ['board'] } })
.output(z.void())
.input(
z.object({
boardName: configNameSchema,
Expand Down Expand Up @@ -89,10 +102,12 @@ export const boardRouter = createTRPCRouter({
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
}),
renameBoard: protectedProcedure
.meta({ openapi: { method: 'PUT', path: '/boards/rename', tags: ['board'] } })
.input(z.object({
oldName: z.string(),
newName: z.string().min(1),
}))
.output(z.void())
.mutation(async ({ input }) => {
if (input.oldName === 'default') {
Consola.error(`Attempted to rename default configuration. Aborted deletion.`);
Expand Down Expand Up @@ -127,9 +142,11 @@ export const boardRouter = createTRPCRouter({
Consola.info(`Deleted ${input.oldName} from file system`);
}),
duplicateBoard: protectedProcedure
.meta({ openapi: { method: 'POST', path: '/boards/duplicate', tags: ['board'] } })
.input(z.object({
boardName: z.string(),
}))
.output(z.void())
.mutation(async ({ input }) => {
if (!configExists(input.boardName)) {
Consola.error(`Tried to duplicate ${input.boardName} but this configuration does not exist.`);
Expand Down
Loading

0 comments on commit c701f72

Please sign in to comment.