diff --git a/api/adminsRoute.ts b/api/adminsRoute.ts index 9666c7c..7c50005 100644 --- a/api/adminsRoute.ts +++ b/api/adminsRoute.ts @@ -1,129 +1,107 @@ import { Model } from "indexed_kv"; import { info } from "std/log/mod.ts"; -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; import { Admin, adminSchema, adminStore } from "../app/adminStore.ts"; import { getUser, withAdmin, withUser } from "./withUser.ts"; export type AdminData = Admin & { id: string }; +const adminDataSchema = t.Object({ + id: t.String(), + tgUserId: t.Number(), + promotedBy: t.Nullable(t.String()), +}); + function getAdminData(adminEntry: Model): AdminData { return { id: adminEntry.id, ...adminEntry.value }; } -export const adminsRoute = createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - {}, - async () => { +export const adminsRoute = new Elysia() + .get( + "", + async () => { + const adminEntries = await adminStore.getAll(); + const admins = adminEntries.map(getAdminData); + return admins; + }, + { + response: t.Array(adminDataSchema), + }, + ) + .post( + "", + async ({ query, body }) => { + return withAdmin(query, async (user, adminEntry) => { + const newAdminUser = await getUser(body.tgUserId); + const newAdminEntry = await adminStore.create({ + tgUserId: body.tgUserId, + promotedBy: adminEntry.id, + }); + info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`); + return getAdminData(newAdminEntry); + }); + }, + { + query: t.Object({ sessionId: t.String() }), + body: t.Object({ + tgUserId: t.Number(), + }), + response: adminDataSchema, + }, + ) + .post( + "/promote_self", + // if there are no admins, allow any user to promote themselves + async ({ query }) => { + return withUser(query, async (user) => { const adminEntries = await adminStore.getAll(); - const admins = adminEntries.map(getAdminData); - return { - status: 200, - body: { type: "application/json", data: admins satisfies AdminData[] }, - }; - }, - ), - POST: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - body: { - type: "application/json", - schema: { - type: "object", - properties: { - tgUserId: adminSchema.properties.tgUserId, - }, - required: ["tgUserId"], - }, - }, - }, - async ({ query, body }) => { - return withAdmin(query, async (user, adminEntry) => { - const newAdminUser = await getUser(body.data.tgUserId); + if (adminEntries.length === 0) { const newAdminEntry = await adminStore.create({ - tgUserId: body.data.tgUserId, - promotedBy: adminEntry.id, + tgUserId: user.id, + promotedBy: null, }); - info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`); - return { - status: 200, - body: { type: "application/json", data: getAdminData(newAdminEntry) }, - }; - }); - }, - ), - }), - - "promote_self": createMethodFilter({ - POST: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - }, - // if there are no admins, allow any user to promote themselves - async ({ query }) => { - return withUser(query, async (user) => { - const adminEntries = await adminStore.getAll(); - if (adminEntries.length === 0) { - const newAdminEntry = await adminStore.create({ - tgUserId: user.id, - promotedBy: null, - }); - info(`User ${user.first_name} promoted themselves to admin`); - return { - status: 200, - body: { type: "application/json", data: getAdminData(newAdminEntry) }, - }; - } - return { - status: 403, - body: { type: "text/plain", data: `You are not allowed to promote yourself` }, - }; - }); - }, - ), - }), - - "{adminId}": createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - {}, - async ({ params }) => { - const adminEntry = await adminStore.getById(params.adminId!); - if (!adminEntry) { - return { status: 404, body: { type: "text/plain", data: `Admin not found` } }; - } - return { - status: 200, - body: { type: "application/json", data: getAdminData(adminEntry) }, - }; - }, - ), - DELETE: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - }, - async ({ params, query }) => { - return withAdmin(query, async (chat) => { - const deletedAdminEntry = await adminStore.getById(params.adminId!); - if (!deletedAdminEntry) { - return { status: 404, body: { type: "text/plain", data: `Admin not found` } }; - } - const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId); - await deletedAdminEntry.delete(); - info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`); - return { - status: 200, - body: { type: "application/json", data: null }, - }; - }); - }, - ), - }), - }), -}); + info(`User ${user.first_name} promoted themselves to admin`); + return getAdminData(newAdminEntry); + } + throw new Error("You are not allowed to promote yourself"); + }); + }, + { + query: t.Object({ sessionId: t.String() }), + response: adminDataSchema, + }, + ) + .get( + "/:adminId", + async ({ params }) => { + const adminEntry = await adminStore.getById(params.adminId!); + if (!adminEntry) { + throw new Error("Admin not found"); + } + return getAdminData(adminEntry); + }, + { + params: t.Object({ adminId: t.String() }), + response: adminDataSchema, + }, + ) + .delete( + "/:adminId", + async ({ params, query }) => { + return withAdmin(query, async (chat) => { + const deletedAdminEntry = await adminStore.getById(params.adminId!); + if (!deletedAdminEntry) { + throw new Error("Admin not found"); + } + const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId); + await deletedAdminEntry.delete(); + info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`); + return null; + }); + }, + { + params: t.Object({ adminId: t.String() }), + query: t.Object({ sessionId: t.String() }), + response: t.Null(), + }, + ); diff --git a/api/botRoute.ts b/api/botRoute.ts index f5466b1..181c5e8 100644 --- a/api/botRoute.ts +++ b/api/botRoute.ts @@ -1,9 +1,14 @@ -import { createEndpoint, createMethodFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; import { bot } from "../bot/mod.ts"; -export const botRoute = createMethodFilter({ - GET: createEndpoint({ query: null, body: null }, async () => { - const username = bot.botInfo.username; - return { status: 200, body: { type: "application/json", data: { username } } }; - }), -}); +export const botRoute = new Elysia() + .get( + "", + async () => { + const username = bot.botInfo.username; + return { username }; + }, + { + response: t.Object({ username: t.String() }), + }, + ); diff --git a/api/jobsRoute.ts b/api/jobsRoute.ts index e7811c4..bda3936 100644 --- a/api/jobsRoute.ts +++ b/api/jobsRoute.ts @@ -1,32 +1,40 @@ -import { createEndpoint, createMethodFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; import { generationQueue } from "../app/generationQueue.ts"; -export const jobsRoute = createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, +export const jobsRoute = new Elysia() + .get( + "", async () => { const allJobs = await generationQueue.getAllJobs(); - const filteredJobsData = allJobs.map((job) => ({ - id: job.id, + return allJobs.map((job) => ({ + id: job.id.join(":"), place: job.place, state: { from: { - language_code: job.state.from.language_code, + language_code: job.state.from.language_code ?? null, first_name: job.state.from.first_name, - last_name: job.state.from.last_name, - username: job.state.from.username, + last_name: job.state.from.last_name ?? null, + username: job.state.from.username ?? null, }, - progress: job.state.progress, - workerInstanceKey: job.state.workerInstanceKey, + progress: job.state.progress ?? null, + workerInstanceKey: job.state.workerInstanceKey ?? null, }, })); - return { - status: 200, - body: { - type: "application/json", - data: filteredJobsData, - }, - }; }, - ), -}); + { + response: t.Array(t.Object({ + id: t.String(), + place: t.Number(), + state: t.Object({ + from: t.Object({ + language_code: t.Nullable(t.String()), + first_name: t.String(), + last_name: t.Nullable(t.String()), + username: t.Nullable(t.String()), + }), + progress: t.Nullable(t.Number()), + workerInstanceKey: t.Nullable(t.String()), + }), + })), + }, + ); diff --git a/api/mod.ts b/api/mod.ts index 13043fd..d59b472 100644 --- a/api/mod.ts +++ b/api/mod.ts @@ -1,12 +1,12 @@ import { route } from "reroute"; import { serveSpa } from "serve_spa"; -import { serveApi } from "./serveApi.ts"; +import { api } from "./serveApi.ts"; import { fromFileUrl } from "std/path/mod.ts"; export async function serveUi() { const server = Deno.serve({ port: 5999 }, (request) => route(request, { - "/api/*": (request) => serveApi(request), + "/api/*": (request) => api.fetch(request), "/*": (request) => serveSpa(request, { fsRoot: fromFileUrl(new URL("../ui/", import.meta.url)), diff --git a/api/paramsRoute.ts b/api/paramsRoute.ts index 23068d1..4721464 100644 --- a/api/paramsRoute.ts +++ b/api/paramsRoute.ts @@ -1,34 +1,46 @@ +import { Elysia, t } from "elysia"; import { deepMerge } from "std/collections/deep_merge.ts"; import { info } from "std/log/mod.ts"; -import { createEndpoint, createMethodFilter } from "t_rest/server"; -import { configSchema, getConfig, setConfig } from "../app/config.ts"; +import { getConfig, setConfig } from "../app/config.ts"; +import { omitUndef } from "../utils/omitUndef.ts"; import { withAdmin } from "./withUser.ts"; -export const paramsRoute = createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, +const paramsSchema = t.Partial(t.Object({ + batch_size: t.Number(), + n_iter: t.Number(), + width: t.Number(), + height: t.Number(), + steps: t.Number(), + cfg_scale: t.Number(), + sampler_name: t.String(), + negative_prompt: t.String(), +})); + +export const paramsRoute = new Elysia() + .get( + "", async () => { const config = await getConfig(); - return { status: 200, body: { type: "application/json", data: config.defaultParams } }; + return omitUndef(config.defaultParams); }, - ), - - PATCH: createEndpoint( { - query: { sessionId: { type: "string" } }, - body: { - type: "application/json", - schema: configSchema.properties.defaultParams, - }, + response: paramsSchema, }, + ) + .patch( + "", async ({ query, body }) => { return withAdmin(query, async (user) => { const config = await getConfig(); - info(`User ${user.first_name} updated default params: ${JSON.stringify(body.data)}`); - const defaultParams = deepMerge(config.defaultParams ?? {}, body.data); + info(`User ${user.first_name} updated default params: ${JSON.stringify(body)}`); + const defaultParams = deepMerge(config.defaultParams ?? {}, body); await setConfig({ defaultParams }); - return { status: 200, body: { type: "application/json", data: config.defaultParams } }; + return omitUndef(config.defaultParams); }); }, - ), -}); + { + query: t.Object({ sessionId: t.String() }), + body: paramsSchema, + response: paramsSchema, + }, + ); diff --git a/api/serveApi.ts b/api/serveApi.ts index 8559f46..36ae5dd 100644 --- a/api/serveApi.ts +++ b/api/serveApi.ts @@ -1,4 +1,5 @@ -import { createLoggerMiddleware, createPathFilter } from "t_rest/server"; +import { Elysia } from "elysia"; +import { swagger } from "elysia/swagger"; import { adminsRoute } from "./adminsRoute.ts"; import { botRoute } from "./botRoute.ts"; import { jobsRoute } from "./jobsRoute.ts"; @@ -8,18 +9,24 @@ import { statsRoute } from "./statsRoute.ts"; import { usersRoute } from "./usersRoute.ts"; import { workersRoute } from "./workersRoute.ts"; -export const serveApi = createLoggerMiddleware( - createPathFilter({ - "admins": adminsRoute, - "bot": botRoute, - "jobs": jobsRoute, - "sessions": sessionsRoute, - "settings/params": paramsRoute, - "stats": statsRoute, - "users": usersRoute, - "workers": workersRoute, - }), - { filterStatus: (status) => status >= 400 }, -); +export const api = new Elysia() + .use( + swagger({ + path: "/docs", + swaggerOptions: { url: "docs/json" } as never, + documentation: { + info: { title: "Eris API", version: "0.1" }, + servers: [{ url: "/api" }], + }, + }), + ) + .group("/admins", (api) => api.use(adminsRoute)) + .group("/bot", (api) => api.use(botRoute)) + .group("/jobs", (api) => api.use(jobsRoute)) + .group("/sessions", (api) => api.use(sessionsRoute)) + .group("/settings/params", (api) => api.use(paramsRoute)) + .group("/stats", (api) => api.use(statsRoute)) + .group("/users", (api) => api.use(usersRoute)) + .group("/workers", (api) => api.use(workersRoute)); -export type ApiHandler = typeof serveApi; +export type Api = typeof api; diff --git a/api/sessionsRoute.ts b/api/sessionsRoute.ts index a2be5c8..40ee53e 100644 --- a/api/sessionsRoute.ts +++ b/api/sessionsRoute.ts @@ -1,4 +1,4 @@ -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; import { ulid } from "ulid"; export const sessions = new Map(); @@ -7,30 +7,37 @@ export interface Session { userId?: number | undefined; } -export const sessionsRoute = createPathFilter({ - "": createMethodFilter({ - POST: createEndpoint( - { query: null, body: null }, - async () => { - const id = ulid(); - const session: Session = {}; - sessions.set(id, session); - return { status: 200, body: { type: "application/json", data: { id, ...session } } }; - }, - ), - }), - - "{sessionId}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const id = params.sessionId!; - const session = sessions.get(id); - if (!session) { - return { status: 401, body: { type: "text/plain", data: "Session not found" } }; - } - return { status: 200, body: { type: "application/json", data: { id, ...session } } }; - }, - ), - }), -}); +export const sessionsRoute = new Elysia() + .post( + "", + async () => { + const id = ulid(); + const session: Session = {}; + sessions.set(id, session); + return { id, userId: session.userId ?? null }; + }, + { + response: t.Object({ + id: t.String(), + userId: t.Nullable(t.Number()), + }), + }, + ) + .get( + "/:sessionId", + async ({ params }) => { + const id = params.sessionId!; + const session = sessions.get(id); + if (!session) { + throw new Error("Session not found"); + } + return { id, userId: session.userId ?? null }; + }, + { + params: t.Object({ sessionId: t.String() }), + response: t.Object({ + id: t.String(), + userId: t.Nullable(t.Number()), + }), + }, + ); diff --git a/api/statsRoute.ts b/api/statsRoute.ts index 09f814e..2aaab7b 100644 --- a/api/statsRoute.ts +++ b/api/statsRoute.ts @@ -1,5 +1,5 @@ +import { Elysia, t } from "elysia"; import { subMinutes } from "date-fns"; -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; import { getDailyStats } from "../app/dailyStatsStore.ts"; import { generationStore } from "../app/generationStore.ts"; import { globalStats } from "../app/globalStats.ts"; @@ -9,138 +9,149 @@ import { withAdmin } from "./withUser.ts"; const STATS_INTERVAL_MIN = 3; -export const statsRoute = createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async () => { - const after = subMinutes(new Date(), STATS_INTERVAL_MIN); - const generations = await generationStore.getAll({ after }); +export const statsRoute = new Elysia() + .get( + "", + async () => { + const after = subMinutes(new Date(), STATS_INTERVAL_MIN); + const generations = await generationStore.getAll({ after }); - const imagesPerMinute = generations.length / STATS_INTERVAL_MIN; + const imagesPerMinute = generations.length / STATS_INTERVAL_MIN; - const stepsPerMinute = generations - .map((generation) => generation.value.info?.steps ?? 0) - .reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN; + const stepsPerMinute = generations + .map((generation) => generation.value.info?.steps ?? 0) + .reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN; - const pixelsPerMinute = generations - .map((generation) => - (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) - ) - .reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN; + const pixelsPerMinute = generations + .map((generation) => + (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) + ) + .reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN; - const pixelStepsPerMinute = generations - .map((generation) => - (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) * - (generation.value.info?.steps ?? 0) - ) - .reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN; + const pixelStepsPerMinute = generations + .map((generation) => + (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) * + (generation.value.info?.steps ?? 0) + ) + .reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN; - return { - status: 200, - body: { - type: "application/json", - data: { - imageCount: globalStats.imageCount, - stepCount: globalStats.stepCount, - pixelCount: globalStats.pixelCount, - pixelStepCount: globalStats.pixelStepCount, - userCount: globalStats.userIds.length, - imagesPerMinute, - stepsPerMinute, - pixelsPerMinute, - pixelStepsPerMinute, - }, - }, - }; - }, - ), - }), - "daily/{year}/{month}/{day}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const year = Number(params.year); - const month = Number(params.month); - const day = Number(params.day); - const stats = await getDailyStats(year, month, day); - return { - status: 200, - body: { - type: "application/json", - data: { - imageCount: stats.imageCount, - pixelCount: stats.pixelCount, - userCount: stats.userIds.length, - timestamp: stats.timestamp, - }, - }, - }; - }, - ), - }), - "users/{userId}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { + return { + imageCount: globalStats.imageCount, + stepCount: globalStats.stepCount, + pixelCount: globalStats.pixelCount, + pixelStepCount: globalStats.pixelStepCount, + userCount: globalStats.userIds.length, + imagesPerMinute, + stepsPerMinute, + pixelsPerMinute, + pixelStepsPerMinute, + }; + }, + { + response: t.Object({ + imageCount: t.Number(), + stepCount: t.Number(), + pixelCount: t.Number(), + pixelStepCount: t.Number(), + userCount: t.Number(), + imagesPerMinute: t.Number(), + stepsPerMinute: t.Number(), + pixelsPerMinute: t.Number(), + pixelStepsPerMinute: t.Number(), + }), + }, + ) + .get( + "/daily/:year/:month/:day", + async ({ params }) => { + const year = Number(params.year); + const month = Number(params.month); + const day = Number(params.day); + const stats = await getDailyStats(year, month, day); + return { + imageCount: stats.imageCount, + pixelCount: stats.pixelCount, + userCount: stats.userIds.length, + timestamp: stats.timestamp, + }; + }, + { + params: t.Object({ + year: t.Number(), + month: t.Number(), + day: t.Number(), + }), + response: t.Object({ + imageCount: t.Number(), + pixelCount: t.Number(), + userCount: t.Number(), + timestamp: t.Number(), + }), + }, + ) + .get( + "/users/:userId", + async ({ params }) => { + const userId = params.userId; + const stats = await getUserStats(userId); + return { + imageCount: stats.imageCount, + pixelCount: stats.pixelCount, + timestamp: stats.timestamp, + }; + }, + { + params: t.Object({ userId: t.Number() }), + response: t.Object({ + imageCount: t.Number(), + pixelCount: t.Number(), + timestamp: t.Number(), + }), + }, + ) + .get( + "/users/:userId/tagcount", + async ({ params, query }) => { + return withAdmin(query, async () => { const userId = Number(params.userId); const stats = await getUserStats(userId); return { - status: 200, - body: { - type: "application/json", - data: { - imageCount: stats.imageCount, - pixelCount: stats.pixelCount, - timestamp: stats.timestamp, - }, - }, + tagCountMap: stats.tagCountMap, + timestamp: stats.timestamp, }; - }, - ), - }), - "users/{userId}/tagcount": createMethodFilter({ - GET: createEndpoint( - { query: { sessionId: { type: "string" } }, body: null }, - async ({ params, query }) => { - return withAdmin(query, async () => { - const userId = Number(params.userId); - const stats = await getUserStats(userId); - return { - status: 200, - body: { - type: "application/json", - data: { - tagCountMap: stats.tagCountMap, - timestamp: stats.timestamp, - }, - }, - }; - }); - }, - ), - }), - "users/{userId}/daily/{year}/{month}/{day}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const userId = Number(params.userId); - const year = Number(params.year); - const month = Number(params.month); - const day = Number(params.day); - const stats = await getUserDailyStats(userId, year, month, day); - return { - status: 200, - body: { - type: "application/json", - data: { - imageCount: stats.imageCount, - pixelCount: stats.pixelCount, - timestamp: stats.timestamp, - }, - }, - }; - }, - ), - }), -}); + }); + }, + { + params: t.Object({ userId: t.Number() }), + query: t.Object({ sessionId: t.String() }), + response: t.Object({ + tagCountMap: t.Record(t.String(), t.Number()), + timestamp: t.Number(), + }), + }, + ) + .get( + "/users/:userId/daily/:year/:month/:day", + async ({ params }) => { + const { userId, year, month, day } = params; + const stats = await getUserDailyStats(userId, year, month, day); + return { + imageCount: stats.imageCount, + pixelCount: stats.pixelCount, + timestamp: stats.timestamp, + }; + }, + { + params: t.Object({ + userId: t.Number(), + year: t.Number(), + month: t.Number(), + day: t.Number(), + }), + response: t.Object({ + imageCount: t.Number(), + pixelCount: t.Number(), + timestamp: t.Number(), + }), + }, + ); diff --git a/api/usersRoute.ts b/api/usersRoute.ts index 0f4cc69..b50c083 100644 --- a/api/usersRoute.ts +++ b/api/usersRoute.ts @@ -1,50 +1,59 @@ -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; +import { adminStore } from "../app/adminStore.ts"; import { bot } from "../bot/mod.ts"; import { getUser } from "./withUser.ts"; -import { adminStore } from "../app/adminStore.ts"; -export const usersRoute = createPathFilter({ - "{userId}/photo": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const user = await getUser(Number(params.userId!)); - const photoData = user.photo?.small_file_id - ? await fetch( - `https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile( - user.photo.small_file_id, - ).then((file) => file.file_path)}`, - ).then((resp) => resp.arrayBuffer()) - : undefined; - if (!photoData) { - return { status: 404, body: { type: "text/plain", data: "User has no photo" } }; +export const usersRoute = new Elysia() + .get( + "/:userId/photo", + async ({ params }) => { + const user = await getUser(Number(params.userId)); + if (!user.photo) { + throw new Error("User has no photo"); + } + const photoFile = await bot.api.getFile(user.photo.small_file_id); + const photoData = await fetch( + `https://api.telegram.org/file/bot${bot.token}/${photoFile.file_path}`, + ).then((resp) => { + if (!resp.ok) { + throw new Error("Failed to fetch photo"); } - return { - status: 200, - body: { - type: "image/jpeg", - data: new Blob([photoData], { type: "image/jpeg" }), - }, - }; - }, - ), - }), + return resp; + }).then((resp) => resp.arrayBuffer()); - "{userId}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const user = await getUser(Number(params.userId!)); - const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id }); - const admin = adminEntry?.value; - return { - status: 200, - body: { - type: "application/json", - data: { ...user, admin }, - }, - }; - }, - ), - }), -}); + return new Response(new File([photoData], "avatar.jpg", { type: "image/jpeg" })); + }, + { + params: t.Object({ userId: t.String() }), + }, + ) + .get( + "/:userId", + async ({ params }) => { + const user = await getUser(Number(params.userId)); + const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id }); + const admin = adminEntry?.value; + return { + id: user.id, + first_name: user.first_name, + last_name: user.last_name ?? null, + username: user.username ?? null, + bio: user.bio ?? null, + admin: admin ?? null, + }; + }, + { + params: t.Object({ userId: t.String() }), + response: t.Object({ + id: t.Number(), + first_name: t.String(), + last_name: t.Nullable(t.String()), + username: t.Nullable(t.String()), + bio: t.Nullable(t.String()), + admin: t.Nullable(t.Object({ + tgUserId: t.Number(), + promotedBy: t.Nullable(t.String()), + })), + }), + }, + ); diff --git a/api/withUser.ts b/api/withUser.ts index ab637ac..7d3920e 100644 --- a/api/withUser.ts +++ b/api/withUser.ts @@ -1,34 +1,33 @@ import { Chat } from "grammy_types"; import { Model } from "indexed_kv"; -import { Output } from "t_rest/client"; import { Admin, adminStore } from "../app/adminStore.ts"; import { bot } from "../bot/mod.ts"; import { sessions } from "./sessionsRoute.ts"; -export async function withUser( +export async function withUser( query: { sessionId: string }, cb: (user: Chat.PrivateGetChat) => Promise, ) { const session = sessions.get(query.sessionId); if (!session?.userId) { - return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; + throw new Error("Must be logged in"); } const user = await getUser(session.userId); return cb(user); } -export async function withAdmin( +export async function withAdmin( query: { sessionId: string }, cb: (user: Chat.PrivateGetChat, admin: Model) => Promise, ) { const session = sessions.get(query.sessionId); if (!session?.userId) { - return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; + throw new Error("Must be logged in"); } const user = await getUser(session.userId); const [admin] = await adminStore.getBy("tgUserId", { value: session.userId }); if (!admin) { - return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const; + throw new Error("Must be admin"); } return cb(user, admin); } diff --git a/api/workersRoute.ts b/api/workersRoute.ts index bf31c5a..b90b85a 100644 --- a/api/workersRoute.ts +++ b/api/workersRoute.ts @@ -2,21 +2,40 @@ import { subMinutes } from "date-fns"; import { Model } from "indexed_kv"; import createOpenApiFetch from "openapi_fetch"; import { info } from "std/log/mod.ts"; -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; import { activeGenerationWorkers } from "../app/generationQueue.ts"; import { generationStore } from "../app/generationStore.ts"; import * as SdApi from "../app/sdApi.ts"; import { WorkerInstance, - workerInstanceSchema, + workerInstanceSchema as _, workerInstanceStore, } from "../app/workerInstanceStore.ts"; import { getAuthHeader } from "../utils/getAuthHeader.ts"; import { omitUndef } from "../utils/omitUndef.ts"; import { withAdmin } from "./withUser.ts"; -export type WorkerData = Omit & { +const workerInstanceSchema = t.Object({ + name: t.Nullable(t.String()), + key: t.String(), + sdUrl: t.String(), + sdAuth: t.Nullable(t.Object({ + user: t.String(), + password: t.String(), + })), + lastOnlineTime: t.Number(), + lastError: t.Optional(t.Object({ + message: t.String(), + time: t.Number(), + })), +}); + +export type WorkerData = { id: string; + name: string | null; + key: string; + lastError?: { message: string; time: number }; + lastOnlineTime: number | null; isActive: boolean; imagesPerMinute: number; stepsPerMinute: number; @@ -24,6 +43,22 @@ export type WorkerData = Omit & { pixelStepsPerMinute: number; }; +const workerDataSchema = t.Object({ + id: t.String(), + key: t.String(), + name: t.Nullable(t.String()), + lastError: t.Optional(t.Object({ + message: t.String(), + time: t.Number(), + })), + lastOnlineTime: t.Nullable(t.Number()), + isActive: t.Boolean(), + imagesPerMinute: t.Number(), + stepsPerMinute: t.Number(), + pixelsPerMinute: t.Number(), + pixelStepsPerMinute: t.Number(), +}); + const STATS_INTERVAL_MIN = 10; async function getWorkerData(workerInstance: Model): Promise { @@ -51,189 +86,172 @@ async function getWorkerData(workerInstance: Model): Promise sum + pixelSteps, 0) / STATS_INTERVAL_MIN; - return { + return omitUndef({ id: workerInstance.id, key: workerInstance.value.key, name: workerInstance.value.name, lastError: workerInstance.value.lastError, - lastOnlineTime: workerInstance.value.lastOnlineTime, + lastOnlineTime: workerInstance.value.lastOnlineTime ?? null, isActive: activeGenerationWorkers.get(workerInstance.id)?.isProcessing ?? false, imagesPerMinute, stepsPerMinute, pixelsPerMinute, pixelStepsPerMinute, - }; + }); } -export const workersRoute = createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async () => { - const workerInstances = await workerInstanceStore.getAll(); - const workers = await Promise.all(workerInstances.map(getWorkerData)); - return { - status: 200, - body: { type: "application/json", data: workers satisfies WorkerData[] }, - }; - }, - ), - POST: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - body: { - type: "application/json", - schema: workerInstanceSchema, - }, - }, - async ({ query, body }) => { - return withAdmin(query, async (user) => { - const workerInstance = await workerInstanceStore.create(body.data); - info(`User ${user.first_name} created worker ${workerInstance.value.name}`); - return { - status: 200, - body: { type: "application/json", data: await getWorkerData(workerInstance) }, - }; - }); - }, - ), - }), - - "{workerId}": createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - return { - status: 200, - body: { type: "application/json", data: await getWorkerData(workerInstance) }, - }; - }, +export const workersRoute = new Elysia() + .get( + "", + async () => { + const workerInstances = await workerInstanceStore.getAll(); + const workers = await Promise.all(workerInstances.map(getWorkerData)); + return workers; + }, + { + response: t.Array(workerDataSchema), + }, + ) + .post( + "", + async ({ query, body }) => { + return withAdmin(query, async (user) => { + const workerInstance = await workerInstanceStore.create(body); + info(`User ${user.first_name} created worker ${workerInstance.value.name}`); + return await getWorkerData(workerInstance); + }); + }, + { + query: t.Object({ sessionId: t.String() }), + body: t.Omit(workerInstanceSchema, ["lastOnlineTime", "lastError"]), + }, + ) + .get( + "/:workerId", + async ({ params }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId!); + if (!workerInstance) { + throw new Error("Worker not found"); + } + return await getWorkerData(workerInstance); + }, + { + params: t.Object({ workerId: t.String() }), + response: workerDataSchema, + }, + ) + .patch( + "/:workerId", + async ({ params, query, body }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId!); + if (!workerInstance) { + throw new Error("Worker not found"); + } + return withAdmin(query, async (user) => { + info( + `User ${user.first_name} updated worker ${workerInstance.value.name}: ${ + JSON.stringify(body) + }`, + ); + await workerInstance.update(omitUndef(body)); + return await getWorkerData(workerInstance); + }); + }, + { + params: t.Object({ workerId: t.String() }), + query: t.Object({ sessionId: t.String() }), + body: t.Partial(workerInstanceSchema), + }, + ) + .delete( + "/:workerId", + async ({ params, query }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId!); + if (!workerInstance) { + throw new Error("Worker not found"); + } + return withAdmin(query, async (user) => { + info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`); + await workerInstance.delete(); + return null; + }); + }, + { + params: t.Object({ workerId: t.String() }), + query: t.Object({ sessionId: t.String() }), + response: t.Null(), + }, + ) + .get( + "/:workerId/loras", + async ({ params }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId!); + if (!workerInstance) { + throw new Error("Worker not found"); + } + const sdClient = createOpenApiFetch({ + baseUrl: workerInstance.value.sdUrl, + headers: getAuthHeader(workerInstance.value.sdAuth), + }); + const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {}); + if (lorasResponse.error) { + throw new Error( + `Loras request failed: ${lorasResponse["error"]}`, + ); + } + const loras = (lorasResponse.data as Lora[]).map((lora) => ({ + name: lora.name, + alias: lora.alias ?? null, + })); + return loras; + }, + { + params: t.Object({ workerId: t.String() }), + response: t.Array( + t.Object({ + name: t.String(), + alias: t.Nullable(t.String()), + }), ), - PATCH: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - body: { - type: "application/json", - schema: { ...workerInstanceSchema, required: [] }, - }, - }, - async ({ params, query, body }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - return withAdmin(query, async (user) => { - info( - `User ${user.first_name} updated worker ${workerInstance.value.name}: ${ - JSON.stringify(body.data) - }`, - ); - await workerInstance.update(omitUndef(body.data)); - return { - status: 200, - body: { type: "application/json", data: await getWorkerData(workerInstance) }, - }; - }); - }, + }, + ) + .get( + "/:workerId/models", + async ({ params }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId!); + if (!workerInstance) { + throw new Error("Worker not found"); + } + const sdClient = createOpenApiFetch({ + baseUrl: workerInstance.value.sdUrl, + headers: getAuthHeader(workerInstance.value.sdAuth), + }); + const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {}); + if (modelsResponse.error) { + throw new Error( + `Models request failed: ${modelsResponse["error"]}`, + ); + } + const models = modelsResponse.data.map((model) => ({ + title: model.title, + modelName: model.model_name, + hash: model.hash ?? null, + sha256: model.sha256 ?? null, + })); + return models; + }, + { + params: t.Object({ workerId: t.String() }), + response: t.Array( + t.Object({ + title: t.String(), + modelName: t.String(), + hash: t.Nullable(t.String()), + sha256: t.Nullable(t.String()), + }), ), - DELETE: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - body: null, - }, - async ({ params, query }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - return withAdmin(query, async (user) => { - info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`); - await workerInstance.delete(); - return { status: 200, body: { type: "application/json", data: null } }; - }); - }, - ), - }), - - "loras": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - const sdClient = createOpenApiFetch({ - baseUrl: workerInstance.value.sdUrl, - headers: getAuthHeader(workerInstance.value.sdAuth), - }); - const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {}); - if (lorasResponse.error) { - return { - status: 500, - body: { type: "text/plain", data: `Loras request failed: ${lorasResponse["error"]}` }, - }; - } - const loras = (lorasResponse.data as Lora[]).map((lora) => ({ - name: lora.name, - alias: lora.alias ?? null, - })); - return { - status: 200, - body: { type: "application/json", data: loras }, - }; - }, - ), - }), - - "models": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - const sdClient = createOpenApiFetch({ - baseUrl: workerInstance.value.sdUrl, - headers: getAuthHeader(workerInstance.value.sdAuth), - }); - const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {}); - if (modelsResponse.error) { - return { - status: 500, - body: { - type: "text/plain", - data: `Models request failed: ${modelsResponse["error"]}`, - }, - }; - } - const models = modelsResponse.data.map((model) => ({ - title: model.title, - modelName: model.model_name, - hash: model.hash, - sha256: model.sha256, - })); - return { - status: 200, - body: { type: "application/json", data: models }, - }; - }, - ), - }), - }), -}); + }, + ); export interface Lora { name: string; diff --git a/deno.json b/deno.json index 1b838a6..06407c6 100644 --- a/deno.json +++ b/deno.json @@ -11,6 +11,9 @@ "async": "https://deno.land/x/async@v2.0.2/mod.ts", "date-fns": "https://cdn.skypack.dev/date-fns@2.30.0?dts", "date-fns/utc": "https://cdn.skypack.dev/@date-fns/utc@1.1.0?dts", + "elysia": "https://esm.sh/elysia@0.7.21?dev", + "elysia/eden": "https://esm.sh/@elysiajs/eden@0.7.4?dev", + "elysia/swagger": "https://esm.sh/@elysiajs/swagger@0.7.4?dev", "exifreader": "https://esm.sh/exifreader@4.14.1", "file_type": "https://esm.sh/file-type@18.5.0", "grammy": "https://lib.deno.dev/x/grammy@1/mod.ts", diff --git a/deno.lock b/deno.lock index 81245d0..c0fe17e 100644 --- a/deno.lock +++ b/deno.lock @@ -299,10 +299,12 @@ "https://deno.land/x/swc@0.2.1/lib/deno_swc.generated.js": "a112eb5a4871bcbd5b660d3fd9d7afdd5cf2bb9e135eb9b04aceaa24d16481a0", "https://deno.land/x/swc@0.2.1/mod.ts": "9e03f5daf4c2a25208e34e19ce3e997d2e23a5a11ee8c8ed58023b0e3626073f", "https://deno.land/x/ulid@v0.3.0/mod.ts": "f7ff065b66abd485051fc68af23becef6ccc7e81f7774d7fcfd894a4b2da1984", + "https://esm.sh/@elysiajs/swagger@0.7.4?dev": "78520881a756f6c3a69ccc1f306bc32e258c6e0e27f971f68231763fbafd303e", "https://esm.sh/@grammyjs/types@2.0.0": "5e5d1ae9f014cb64f0af36fb91f9d35414d670049b77367818a70cd62092b2ac", "https://esm.sh/@grammyjs/types@2.12.1": "eebe448d3bf3d4fdaeacee50e31b9e3947ce965d2591f1036e5c0273cb7aec36", "https://esm.sh/@twind/core@1.1.3": "bf3e64c13de95b061a721831bf2aed322f6e7335848b06d805168c3212686c1d", "https://esm.sh/@twind/preset-tailwind@1.1.4": "29f5e4774c344ff08d2636e3d86382060a8b40d6bce1c158b803df1823c2804c", + "https://esm.sh/elysia@0.7.21?dev": "6e954350858d5f2fd6c8e61dadf8d9989186f436ccdeb850606359d5e163298c", "https://esm.sh/exifreader@4.14.1": "d0f21973393b0d1a6ed329dac8fcfb2f87ce47fe40b8172e205e7d6d85790bb6", "https://esm.sh/file-type@18.5.0": "f01f6eddb05d365925545e26bd449f934dc042bead882b2795c35c4f48d690a3", "https://esm.sh/openapi-fetch@0.7.6": "43eff6df93e773801cb6ade02b0f47c05d0bfe2fb044adbf2b94fa74ff30d35b", @@ -321,6 +323,7 @@ "https://esm.sh/use-local-storage@3.0.0?external=react&dev": "4cf7fce754a9488940daa76051389421c6341420cae5b8c7d2401158ffe79ec0", "https://esm.sh/v132/@twind/core@1.1.3/denonext/core.mjs": "c2618087a5d5cc406c7dc1079015f4d7cc874bee167f74e9945694896d907b6d", "https://esm.sh/v132/@twind/preset-tailwind@1.1.4/denonext/preset-tailwind.mjs": "3cb9f5cde89e11cd2adad54ff264f62f5000ccb1694cd88874b1856eb2d8d7f7", + "https://esm.sh/v133/@elysiajs/swagger@0.7.4/denonext/swagger.development.mjs": "63cc67eee29123918283027744da23009fc3767088c513f8bfbfef945b7170d1", "https://esm.sh/v133/@formatjs/ecma402-abstract@1.17.2/X-ZS9yZWFjdA/denonext/ecma402-abstract.development.mjs": "178a303e29d73369b4c7c1da5a18393c2baa8742a0e8a45be85f506c17f763d9", "https://esm.sh/v133/@formatjs/fast-memoize@2.2.0/X-ZS9yZWFjdA/denonext/fast-memoize.development.mjs": "4c027b3308490b65dc899f683b1ff9be8da4b7a2e1e32433ef03bcb8f0fdf821", "https://esm.sh/v133/@formatjs/icu-messageformat-parser@2.6.2/X-ZS9yZWFjdA/denonext/icu-messageformat-parser.development.mjs": "68c7a9be44aaa3e35bfe18668e17a2d4a465e11f59f3a1e025ee83fe1bd0b971", @@ -330,14 +333,25 @@ "https://esm.sh/v133/@grammyjs/types@2.0.0/denonext/types.mjs": "7ee61bd0c55a152ea1ffaf3fbe63fce6c103ae836265b23290284d6ba0e3bc5b", "https://esm.sh/v133/@grammyjs/types@2.12.1/denonext/types.mjs": "3636f7a1ca7fef89fa735d832b72193a834bc7f5250b6bf182544be53a6ab218", "https://esm.sh/v133/@remix-run/router@1.9.0/X-ZS9yZWFjdA/denonext/router.development.mjs": "97f96f031a7298b0afbbadb23177559ee9b915314d7adf0b9c6a8d7f452c70e7", + "https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/compiler.development.js": "94129b8b2f26f79bd1ec51f479776e7cdebe521f60bd2eae695634765959f7b9", + "https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/system.development.js": "e2c96ea22f15b9e79e8021b65ae62bfd1b4fd94176dd9c8fc1042fa014c64e9e", + "https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/typebox.development.mjs": "d5776065180eea03471f3f2c992a0cf8289219881c40b68270f543536f5ee029", + "https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/value.development.js": "41db30d8ba66c836ad5faf931f7ce38d15f943339847b8a7491014f8fe2be451", "https://esm.sh/v133/client-only@0.0.1/X-ZS9yZWFjdA/denonext/client-only.development.mjs": "b7efaef2653f7f628d084e24a4d5a857c9cd1a5e5060d1d9957e185ee98c8a28", + "https://esm.sh/v133/cookie@0.5.0/denonext/cookie.development.mjs": "04344caac1f8341ced6ec11d15b95a36dbda70a8ebb6a809b20b5912a5de2b8b", "https://esm.sh/v133/crc-32@0.3.0/denonext/crc-32.mjs": "92bbd96cd5a92e45267cf4b3d3928b9355d16963da1ba740637fb10f1daca590", + "https://esm.sh/v133/elysia@0.7.21/denonext/elysia.development.mjs": "4bf283f62b935737d33ab4e3e59195ba9cb0ea2fc2bac90c8e9fa00a44b5e575", + "https://esm.sh/v133/eventemitter3@5.0.1/denonext/eventemitter3.development.mjs": "e19a3e8d30d7564ce7b394636fc66ef273c6394bb6c5820b9a9bfba7ec4ac2dd", "https://esm.sh/v133/exifreader@4.14.1/denonext/exifreader.mjs": "691e1c1d1337ccaf092bf39115fdac56bf69e93259d04bb03985e918202317ab", + "https://esm.sh/v133/fast-decode-uri-component@1.0.1/denonext/fast-decode-uri-component.development.mjs": "004912e1f391fccf376cf58ff1ab06d6d4ed241cab9c7bed23756091bedbdc36", + "https://esm.sh/v133/fast-querystring@1.1.2/denonext/fast-querystring.development.mjs": "da06ef49d7e834dbac2b3b189de02c80e71cd5942b339de0011ab84aae0de0ff", "https://esm.sh/v133/file-type@18.5.0/denonext/file-type.mjs": "785cac1bc363448647871e1b310422e01c9cfb7b817a68690710b786d3598311", "https://esm.sh/v133/hoist-non-react-statics@3.3.2/X-ZS9yZWFjdA/denonext/hoist-non-react-statics.development.mjs": "fcafac9e3c33810f18ecb43dfc32ce80efc88adc63d3f49fd7ada0665d2146c6", "https://esm.sh/v133/ieee754@1.2.1/denonext/ieee754.mjs": "9ec2806065f50afcd4cf3f3f2f38d93e777a92a5954dda00d219f57d4b24832f", "https://esm.sh/v133/inherits@2.0.4/denonext/inherits.mjs": "8095f3d6aea060c904fb24ae50f2882779c0acbe5d56814514c8b5153f3b4b3b", "https://esm.sh/v133/intl-messageformat@10.5.3/X-ZS9yZWFjdA/denonext/intl-messageformat.development.mjs": "d6b701c562c51ec4b4e1deb641c6623986abe304290bbb291e6404cb6a31dc41", + "https://esm.sh/v133/lodash.clonedeep@4.5.0/denonext/lodash.clonedeep.development.mjs": "f44c863cb41bcd2714103a36d97e8eb8268f56070b5cd56dcfae26c556e6622e", + "https://esm.sh/v133/memoirist@0.1.4/denonext/memoirist.development.mjs": "e19f8379a684345ebcab9a688b0b188f29120f4bf1122588b779113cd1ec0e4e", "https://esm.sh/v133/openapi-fetch@0.7.6/denonext/openapi-fetch.mjs": "1ec8ed23c9141c7f4e58de06f84525e310fe7dda1aeaf675c8edafb3d8292cfc", "https://esm.sh/v133/peek-readable@5.0.0/denonext/peek-readable.mjs": "5e799ea86e9c501873f687eda9c891a75ed55ba666b5dd822eaa3d28a8a5f2b1", "https://esm.sh/v133/png-chunk-text@1.0.0/denonext/png-chunk-text.mjs": "e8bb89595ceab2603531693319da08a9dd90d51169437de47a73cf8bac7baa11", diff --git a/ui/AdminsPage.tsx b/ui/AdminsPage.tsx index 4e76406..295d2c3 100644 --- a/ui/AdminsPage.tsx +++ b/ui/AdminsPage.tsx @@ -11,18 +11,18 @@ export function AdminsPage(props: { sessionId: string | null }) { const addDialogRef = useRef(null); const getSession = useSWR( - sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getUser = useSWR( getSession.data?.userId - ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + ? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getAdmins = useSWR( - ["admins", "GET", {}] as const, + ["/admins", { method: "GET" }] as const, (args) => fetchApi(...args).then(handleResponse), ); @@ -35,7 +35,8 @@ export function AdminsPage(props: { sessionId: string | null }) { mutate( (key) => Array.isArray(key) && key[0] === "admins", async () => - fetchApi("admins/promote_self", "POST", { + fetchApi("/admins/promote_self", { + method: "POST", query: { sessionId: sessionId ?? "" }, }).then(handleResponse), { populateCache: false }, @@ -102,13 +103,11 @@ function AddAdminDialog(props: { mutate( (key) => Array.isArray(key) && key[0] === "admins", async () => - fetchApi("admins", "POST", { + fetchApi("/admins", { + method: "POST", query: { sessionId: sessionId! }, body: { - type: "application/json", - data: { - tgUserId: Number(data.get("tgUserId") as string), - }, + tgUserId: Number(data.get("tgUserId") as string), }, }).then(handleResponse), { populateCache: false }, @@ -152,17 +151,17 @@ function AdminListItem(props: { admin: AdminData; sessionId: string | null }) { const deleteDialogRef = useRef(null); const getAdminUser = useSWR( - ["users/{userId}", "GET", { params: { userId: String(admin.tgUserId) } }] as const, + ["/users/:userId", { params: { userId: String( admin.tgUserId) } }] as const, (args) => fetchApi(...args).then(handleResponse), ); const getSession = useSWR( - sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getUser = useSWR( getSession.data?.userId - ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + ? ["/users/:userId", { params: { userId: String( getSession.data.userId) } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); @@ -226,7 +225,8 @@ function DeleteAdminDialog(props: { mutate( (key) => Array.isArray(key) && key[0] === "admins", async () => - fetchApi("admins/{adminId}", "DELETE", { + fetchApi("/admins/:adminId", { + method: "DELETE", query: { sessionId: sessionId! }, params: { adminId: adminId }, }).then(handleResponse), diff --git a/ui/App.tsx b/ui/App.tsx index 48dbd0d..35581f4 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -16,7 +16,7 @@ export function App() { // initialize a new session when there is no session ID useEffect(() => { if (!sessionId) { - fetchApi("sessions", "POST", {}).then(handleResponse).then((session) => { + fetchApi("/sessions", { method: "POST" }).then(handleResponse).then((session) => { console.log("Initialized session", session.id); setSessionId(session.id); }); diff --git a/ui/AppHeader.tsx b/ui/AppHeader.tsx index 96a2cad..b0c8876 100644 --- a/ui/AppHeader.tsx +++ b/ui/AppHeader.tsx @@ -23,32 +23,45 @@ export function AppHeader(props: { const { className, sessionId, onLogOut } = props; const getSession = useSWR( - sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + sessionId ? ["/sessions/:sessionId", { method: "GET", params: { sessionId } }] as const : null, (args) => fetchApi(...args).then(handleResponse), { onError: () => onLogOut() }, ); const getUser = useSWR( getSession.data?.userId - ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + ? ["/users/:userId", { + method: "GET", + params: { userId: String(getSession.data.userId) }, + }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getBot = useSWR( - ["bot", "GET", {}] as const, + ["/bot", { method: "GET" }] as const, (args) => fetchApi(...args).then(handleResponse), ); const getUserPhoto = useSWR( getSession.data?.userId - ? ["users/{userId}/photo", "GET", { + ? ["/users/:userId/photo", { + method: "GET", params: { userId: String(getSession.data.userId) }, }] as const : null, - (args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)), + (args) => + fetchApi(...args).then((response) => response.response) + .then((response) => { + if (!response.ok) throw new Error(response.statusText); + return response; + }) + .then((response) => response.blob()) + .then((blob) => blob ? URL.createObjectURL(blob) : null), ); + console.log(getUserPhoto); + return (
fetchApi(...args).then(handleResponse), { refreshInterval: 2000 }, ); @@ -25,10 +25,10 @@ export function QueuePage() { getJobs.data?.map((job) => (
  • {job.place}. - {getFlagEmoji(job.state.from.language_code)} + {getFlagEmoji(job.state.from.language_code ?? undefined)} {job.state.from.first_name} {job.state.from.last_name} {job.state.from.username ? ( diff --git a/ui/SettingsPage.tsx b/ui/SettingsPage.tsx index c5ec7b6..38e6387 100644 --- a/ui/SettingsPage.tsx +++ b/ui/SettingsPage.tsx @@ -2,22 +2,23 @@ import React, { useState } from "react"; import useSWR from "swr"; import { cx } from "twind/core"; import { fetchApi, handleResponse } from "./apiClient.ts"; +import { omitUndef } from "../utils/omitUndef.ts"; export function SettingsPage(props: { sessionId: string | null }) { const { sessionId } = props; const getSession = useSWR( - sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getUser = useSWR( getSession.data?.userId - ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + ? ["/users/:userId", { params: { userId: String( getSession.data.userId) } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getParams = useSWR( - ["settings/params", "GET", {}] as const, + ["/settings/params", {}] as const, (args) => fetchApi(...args).then(handleResponse), ); const [newParams, setNewParams] = useState>({}); @@ -29,9 +30,10 @@ export function SettingsPage(props: { sessionId: string | null }) { onSubmit={(e) => { e.preventDefault(); getParams.mutate(() => - fetchApi("settings/params", "PATCH", { + fetchApi("/settings/params", { + method: "PATCH", query: { sessionId: sessionId ?? "" }, - body: { type: "application/json", data: newParams ?? {} }, + body: omitUndef(newParams ?? {}), }).then(handleResponse) ) .then(() => setNewParams({})) diff --git a/ui/StatsPage.tsx b/ui/StatsPage.tsx index dc4870e..8c0cabe 100644 --- a/ui/StatsPage.tsx +++ b/ui/StatsPage.tsx @@ -5,7 +5,7 @@ import { Counter } from "./Counter.tsx"; export function StatsPage() { const getGlobalStats = useSWR( - ["stats", "GET", {}] as const, + ["/stats", {}] as const, (args) => fetchApi(...args).then(handleResponse), { refreshInterval: 2_000 }, ); diff --git a/ui/WorkersPage.tsx b/ui/WorkersPage.tsx index 33cfd0a..01e0ee7 100644 --- a/ui/WorkersPage.tsx +++ b/ui/WorkersPage.tsx @@ -11,18 +11,18 @@ export function WorkersPage(props: { sessionId: string | null }) { const addDialogRef = useRef(null); const getSession = useSWR( - sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getUser = useSWR( getSession.data?.userId - ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + ? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getWorkers = useSWR( - ["workers", "GET", {}] as const, + ["/workers", { method: "GET" }] as const, (args) => fetchApi(...args).then(handleResponse), { refreshInterval: 5000 }, ); @@ -91,16 +91,14 @@ function AddWorkerDialog(props: { mutate( (key) => Array.isArray(key) && key[0] === "workers", async () => - fetchApi("workers", "POST", { + fetchApi("/workers", { + method: "POST", query: { sessionId: sessionId! }, body: { - type: "application/json", - data: { - key, - name: name || null, - sdUrl, - sdAuth: user && password ? { user, password } : null, - }, + key, + name: name || null, + sdUrl, + sdAuth: user && password ? { user, password } : null, }, }).then(handleResponse), { populateCache: false }, @@ -198,12 +196,12 @@ function WorkerListItem(props: { const deleteDialogRef = useRef(null); const getSession = useSWR( - sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getUser = useSWR( getSession.data?.userId - ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + ? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); @@ -299,14 +297,12 @@ function EditWorkerDialog(props: { mutate( (key) => Array.isArray(key) && key[0] === "workers", async () => - fetchApi("workers/{workerId}", "PATCH", { + fetchApi("/workers/:workerId", { + method: "PATCH", params: { workerId: workerId }, query: { sessionId: sessionId! }, body: { - type: "application/json", - data: { - sdAuth: user && password ? { user, password } : null, - }, + sdAuth: user && password ? { user, password } : null, }, }).then(handleResponse), { populateCache: false }, @@ -374,7 +370,8 @@ function DeleteWorkerDialog(props: { mutate( (key) => Array.isArray(key) && key[0] === "workers", async () => - fetchApi("workers/{workerId}", "DELETE", { + fetchApi("/workers/:workerId", { + method: "DELETE", params: { workerId: workerId }, query: { sessionId: sessionId! }, }).then(handleResponse), diff --git a/ui/apiClient.ts b/ui/apiClient.ts index d851fec..80b1556 100644 --- a/ui/apiClient.ts +++ b/ui/apiClient.ts @@ -1,15 +1,13 @@ -import { createFetcher, Output } from "t_rest/client"; -import { ApiHandler } from "../api/serveApi.ts"; +import { edenFetch } from "elysia/eden"; +import { Api } from "../api/serveApi.ts"; -export const fetchApi = createFetcher({ - baseUrl: `${location.origin}/api/`, -}); +export const fetchApi = edenFetch(`${location.origin}/api`); -export function handleResponse( - response: T, -): (T & { status: 200 })["body"]["data"] { - if (response.status !== 200) { - throw new Error(String(response.body.data)); +export function handleResponse( + response: { data: D; error: E }, +): NonNullable { + if (response.data) { + return response.data; } - return response.body.data; + throw new Error(String(response.error)); }