feature: add trpc openapi (#1818)
This commit is contained in:
@@ -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);
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
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');
|
||||
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);
|
||||
return await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const name = file.replace('.json', '');
|
||||
const config = await getFrontendConfig(name);
|
||||
|
||||
const countApps = config.apps.length;
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
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,
|
||||
@@ -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.`);
|
||||
@@ -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.`);
|
||||
|
||||
@@ -15,11 +15,13 @@ import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const configRouter = createTRPCRouter({
|
||||
delete: adminProcedure
|
||||
.meta({ openapi: { method: 'DELETE', path: '/configs', tags: ['config'] } })
|
||||
.input(
|
||||
z.object({
|
||||
name: configNameSchema,
|
||||
}),
|
||||
)
|
||||
.output(z.object({ message: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
if (input.name.toLowerCase() === 'default') {
|
||||
Consola.error('Rejected config deletion because default configuration can\'t be deleted');
|
||||
@@ -160,11 +162,21 @@ export const configRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
byName: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/configs/byName',
|
||||
tags: ['config'],
|
||||
deprecated: true,
|
||||
summary: 'Retrieve content of the JSON configuration. Deprecated because JSON will be removed in a future version and be replaced with a relational database.'
|
||||
}
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
name: configNameSchema,
|
||||
}),
|
||||
)
|
||||
.output(z.custom<ConfigType>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!configExists(input.name)) {
|
||||
throw new TRPCError({
|
||||
@@ -177,6 +189,7 @@ export const configRouter = createTRPCRouter({
|
||||
}),
|
||||
saveCustomization: adminProcedure
|
||||
.input(boardCustomizationSchema.and(z.object({ name: configNameSchema })))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input }) => {
|
||||
const previousConfig = getConfig(input.name);
|
||||
const newConfig = {
|
||||
|
||||
@@ -4,35 +4,40 @@ import { LocalIconsRepository } from '~/tools/server/images/local-icons-reposito
|
||||
import { UnpkgIconsRepository } from '~/tools/server/images/unpkg-icons-repository';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { z } from 'zod';
|
||||
import { NormalizedIconRepositoryResult } from '~/tools/server/images/abstract-icons-repository';
|
||||
|
||||
export const IconRespositories = [
|
||||
new LocalIconsRepository(),
|
||||
new GitHubIconsRepository(
|
||||
GitHubIconsRepository.walkxcode,
|
||||
'Walkxcode Dashboard Icons',
|
||||
'Walkxcode on Github'
|
||||
'Walkxcode on Github',
|
||||
),
|
||||
new UnpkgIconsRepository(
|
||||
UnpkgIconsRepository.tablerRepository,
|
||||
'Tabler Icons',
|
||||
'Tabler Icons - GitHub (MIT)'
|
||||
'Tabler Icons - GitHub (MIT)',
|
||||
),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.papirusRepository,
|
||||
'Papirus Icons',
|
||||
'Papirus Development Team on GitHub (Apache 2.0)'
|
||||
'Papirus Development Team on GitHub (Apache 2.0)',
|
||||
),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.homelabSvgAssetsRepository,
|
||||
'Homelab Svg Assets',
|
||||
'loganmarchione on GitHub (MIT)'
|
||||
'loganmarchione on GitHub (MIT)',
|
||||
),
|
||||
];
|
||||
|
||||
export const iconRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async () => {
|
||||
const fetches = IconRespositories.map((rep) => rep.fetch());
|
||||
const data = await Promise.all(fetches);
|
||||
return data;
|
||||
}),
|
||||
all: publicProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/icons', tags: ['icon'] } })
|
||||
.input(z.void())
|
||||
.output(z.array(z.custom<NormalizedIconRepositoryResult>()))
|
||||
.query(async () => {
|
||||
const fetches = IconRespositories.map((rep) => rep.fetch());
|
||||
return await Promise.all(fetches);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,17 +5,26 @@ import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { invites } from '~/server/db/schema';
|
||||
|
||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../../trpc';
|
||||
import { adminProcedure, createTRPCRouter } from '../../trpc';
|
||||
|
||||
export const inviteRouter = createTRPCRouter({
|
||||
all: adminProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/invites', tags: ['invite'] } })
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(100).default(10),
|
||||
page: z.number().min(0),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
.output(z.object({
|
||||
invites: z.array(z.object({
|
||||
id: z.string(),
|
||||
expires: z.date(),
|
||||
creator: z.string().or(z.null()),
|
||||
})),
|
||||
countPages: z.number().min(0),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const limit = input.limit;
|
||||
const dbInvites = await db.query.invites.findMany({
|
||||
limit: limit,
|
||||
@@ -44,14 +53,20 @@ export const inviteRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
create: adminProcedure
|
||||
.meta({ openapi: { method: 'POST', path: '/invites', tags: ['invite'] } })
|
||||
.input(
|
||||
z.object({
|
||||
expiration: z
|
||||
.date()
|
||||
.min(dayjs().add(5, 'minutes').toDate())
|
||||
.max(dayjs().add(6, 'months').toDate()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.output(z.object({
|
||||
id: z.string(),
|
||||
token: z.string(),
|
||||
expires: z.date(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const inviteToInsert = {
|
||||
id: randomUUID(),
|
||||
@@ -67,7 +82,11 @@ export const inviteRouter = createTRPCRouter({
|
||||
expires: inviteToInsert.expires,
|
||||
};
|
||||
}),
|
||||
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
|
||||
await db.delete(invites).where(eq(invites.id, input.id));
|
||||
}),
|
||||
delete: adminProcedure
|
||||
.meta({ openapi: { method: 'DELETE', path: '/invites', tags: ['invite'] } })
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input }) => {
|
||||
await db.delete(invites).where(eq(invites.id, input.id));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
updateSettingsValidationSchema,
|
||||
} from '~/validations/user';
|
||||
import { PossibleRoleFilter } from '~/pages/manage/users';
|
||||
import { createSelectSchema } from 'drizzle-zod';
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
|
||||
@@ -41,6 +42,7 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
updatePassword: adminProcedure
|
||||
.meta({ openapi: { method: 'PUT', path: '/users/password', tags: ['user'] } })
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
@@ -48,6 +50,7 @@ export const userRouter = createTRPCRouter({
|
||||
terminateExistingSessions: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
@@ -81,9 +84,13 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
count: publicProcedure.query(async () => {
|
||||
return await getTotalUserCountAsync();
|
||||
}),
|
||||
count: publicProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/users/count', tags: ['user'] } })
|
||||
.input(z.void())
|
||||
.output(z.number())
|
||||
.query(async () => {
|
||||
return await getTotalUserCountAsync();
|
||||
}),
|
||||
createFromInvite: publicProcedure
|
||||
.input(
|
||||
signUpFormSchema.and(
|
||||
@@ -133,7 +140,9 @@ export const userRouter = createTRPCRouter({
|
||||
.where(eq(userSettings.userId, ctx.session?.user?.id));
|
||||
}),
|
||||
changeRole: adminProcedure
|
||||
.meta({ openapi: { method: 'PUT', path: '/users/roles', tags: ['user'] } })
|
||||
.input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) }))
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.session?.user?.id === input.id) {
|
||||
throw new TRPCError({
|
||||
@@ -166,11 +175,13 @@ export const userRouter = createTRPCRouter({
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeLanguage: protectedProcedure
|
||||
.meta({ openapi: { method: 'PUT', path: '/users/language', tags: ['user'] } })
|
||||
.input(
|
||||
z.object({
|
||||
language: z.string(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db
|
||||
.update(userSettings)
|
||||
@@ -218,6 +229,8 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
makeDefaultDashboard: protectedProcedure
|
||||
.meta({ openapi: { method: 'POST', path: '/users/make-default-dashboard', tags: ['user'] } })
|
||||
.output(z.void())
|
||||
.input(z.object({ board: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db
|
||||
@@ -243,7 +256,20 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
.output(z.object({
|
||||
users: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string().or(z.null()).optional(),
|
||||
isAdmin: z.boolean(),
|
||||
isOwner: z.boolean(),
|
||||
})),
|
||||
countPages: z.number().min(0),
|
||||
stats: z.object({
|
||||
roles: z.record(z.number()),
|
||||
}),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
|
||||
const roleFilter = () => {
|
||||
if (input.search.role === PossibleRoleFilter[1].id) {
|
||||
@@ -309,30 +335,54 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
}),
|
||||
create: adminProcedure.input(createNewUserSchema).mutation(async ({ input }) => {
|
||||
await createUserIfNotPresent(input);
|
||||
}),
|
||||
details: adminProcedure.input(z.object({ userId: z.string() })).query(async ({ input }) => {
|
||||
return db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
}),
|
||||
updateDetails: adminProcedure.input(z.object({
|
||||
userId: z.string(),
|
||||
username: z.string(),
|
||||
eMail: z.string().optional().transform(value => value?.length === 0 ? null : value),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.update(users).set({
|
||||
name: input.username,
|
||||
email: input.eMail as string | null,
|
||||
}).where(eq(users.id, input.userId));
|
||||
}),
|
||||
create: adminProcedure
|
||||
.meta({ openapi: { method: 'POST', path: '/users', tags: ['user'] } })
|
||||
.input(createNewUserSchema)
|
||||
.output(z.void())
|
||||
.mutation(async ({ input }) => {
|
||||
await createUserIfNotPresent(input);
|
||||
}),
|
||||
details: adminProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/users/getById', tags: ['user'] } })
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.output(
|
||||
createSelectSchema(users)
|
||||
.omit({
|
||||
password: true,
|
||||
salt: true,
|
||||
})
|
||||
.optional())
|
||||
.query(async ({ input }) => {
|
||||
return db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
columns: {
|
||||
password: false,
|
||||
salt: false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
updateDetails: adminProcedure
|
||||
.meta({ openapi: { method: 'PUT', path: '/users/details', tags: ['user'] } })
|
||||
.input(z.object({
|
||||
userId: z.string(),
|
||||
username: z.string(),
|
||||
eMail: z.string().optional().transform(value => value?.length === 0 ? null : value),
|
||||
}))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input }) => {
|
||||
await db.update(users).set({
|
||||
name: input.username,
|
||||
email: input.eMail as string | null,
|
||||
}).where(eq(users.id, input.userId));
|
||||
}),
|
||||
deleteUser: adminProcedure
|
||||
.meta({ openapi: { method: 'DELETE', path: '/users', tags: ['user'] } })
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, input.id),
|
||||
|
||||
@@ -13,6 +13,7 @@ import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { getServerAuthSession } from '../auth';
|
||||
import { OpenApiMeta } from 'trpc-openapi';
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
@@ -70,7 +71,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
* errors on the backend.
|
||||
*/
|
||||
|
||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
||||
const t = initTRPC.context<typeof createTRPCContext>().meta<OpenApiMeta>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
|
||||
11
src/server/openai.ts
Normal file
11
src/server/openai.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { generateOpenApiDocument } from 'trpc-openapi';
|
||||
import { appRouter } from '~/server/api/routers/app';
|
||||
import { rootRouter } from '~/server/api/root';
|
||||
|
||||
export const openApiDocument = generateOpenApiDocument(rootRouter, {
|
||||
title: 'Homarr API',
|
||||
description: 'OpenAPI compliant REST API built of interfacing with Homarr',
|
||||
version: '1.0.0',
|
||||
baseUrl: 'http://localhost:3000/api',
|
||||
docsUrl: 'https://homarr.dev'
|
||||
});
|
||||
Reference in New Issue
Block a user