docs: improve doucmentation

improve doucmentation and fix small store changes
This commit is contained in:
2026-04-30 01:16:48 +02:00
parent 3f92e74350
commit 0a714428f6
31 changed files with 4161 additions and 23 deletions

View File

@@ -5,6 +5,15 @@ export interface OpenApiBuildOptions {
export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
const { baseUrl, appVersion } = options;
const roomIdPathParameter = { name: 'roomId', in: 'path', required: true, schema: { type: 'string' } };
const userIdPathParameter = { name: 'userId', in: 'path', required: true, schema: { type: 'string' } };
const messageIdPathParameter = { name: 'messageId', in: 'path', required: true, schema: { type: 'string' } };
const sinceTimestampQueryParameter = {
name: 'sinceTimestamp',
in: 'query',
required: true,
schema: { type: 'integer', minimum: 0, format: 'int64' }
};
return {
openapi: '3.1.0',
@@ -96,6 +105,19 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
},
additionalProperties: true
},
User: {
type: 'object',
properties: {
id: { type: 'string' },
oderId: { type: 'string' },
username: { type: 'string' },
displayName: { type: 'string' },
status: { type: 'string' },
role: { type: 'string' },
isOnline: { type: 'boolean' }
},
additionalProperties: true
},
Message: {
type: 'object',
properties: {
@@ -110,6 +132,59 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
isDeleted: { type: 'boolean' }
},
additionalProperties: true
},
Reaction: {
type: 'object',
properties: {
id: { type: 'string' },
messageId: { type: 'string' },
userId: { type: 'string' },
oderId: { type: 'string' },
emoji: { type: 'string' },
timestamp: { type: 'integer', format: 'int64' }
},
additionalProperties: true
},
Attachment: {
type: 'object',
properties: {
id: { type: 'string' },
messageId: { type: 'string' },
filename: { type: 'string' },
size: { type: 'integer' },
mime: { type: 'string' },
isImage: { type: 'boolean' },
filePath: { type: 'string' },
savedPath: { type: 'string' }
},
additionalProperties: true
},
Ban: {
type: 'object',
properties: {
oderId: { type: 'string' },
roomId: { type: 'string' },
userId: { type: 'string' },
bannedBy: { type: 'string' },
displayName: { type: 'string' },
reason: { type: 'string' },
expiresAt: { type: 'integer', format: 'int64' },
timestamp: { type: 'integer', format: 'int64' }
},
additionalProperties: true
},
PluginDataValue: {
type: 'object',
properties: {
value: {}
}
},
MetaValue: {
type: 'object',
properties: {
key: { type: 'string' },
value: { type: ['string', 'null'] }
}
}
}
},
@@ -225,11 +300,47 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
}
}
},
'/api/rooms/{roomId}': {
get: {
summary: 'Get a room by id',
parameters: [roomIdPathParameter],
responses: {
'200': {
description: 'Room details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Room' }
}
}
},
'404': {
description: 'Room not found',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }
}
}
}
},
'/api/rooms/{roomId}/users': {
get: {
summary: 'List users known for a room',
parameters: [roomIdPathParameter],
responses: {
'200': {
description: 'Users array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/User' } }
}
}
}
}
}
},
'/api/rooms/{roomId}/messages': {
get: {
summary: 'List messages for a room',
parameters: [
{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } },
roomIdPathParameter,
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 500 } },
{ name: 'offset', in: 'query', required: false, schema: { type: 'integer', minimum: 0 } }
],
@@ -247,6 +358,182 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
}
}
}
},
'/api/rooms/{roomId}/messages/since': {
get: {
summary: 'List room messages after a timestamp',
parameters: [roomIdPathParameter, sinceTimestampQueryParameter],
responses: {
'200': {
description: 'Messages array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Message' } }
}
}
}
}
}
},
'/api/rooms/{roomId}/bans': {
get: {
summary: 'List active bans for a room',
parameters: [roomIdPathParameter],
responses: {
'200': {
description: 'Bans array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Ban' } }
}
}
}
}
}
},
'/api/rooms/{roomId}/bans/{userId}': {
get: {
summary: 'Check whether a user is banned in a room',
parameters: [roomIdPathParameter, userIdPathParameter],
responses: {
'200': {
description: 'Ban status',
content: {
'application/json': {
schema: {
type: 'object',
required: ['isBanned'],
properties: { isBanned: { type: 'boolean' } }
}
}
}
}
}
}
},
'/api/messages/{messageId}': {
get: {
summary: 'Get a message by id',
parameters: [messageIdPathParameter],
responses: {
'200': {
description: 'Message details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Message' }
}
}
},
'404': {
description: 'Message not found',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }
}
}
}
},
'/api/messages/{messageId}/reactions': {
get: {
summary: 'List reactions for a message',
parameters: [messageIdPathParameter],
responses: {
'200': {
description: 'Reactions array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Reaction' } }
}
}
}
}
}
},
'/api/messages/{messageId}/attachments': {
get: {
summary: 'List attachments for a message',
parameters: [messageIdPathParameter],
responses: {
'200': {
description: 'Attachments array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } }
}
}
}
}
}
},
'/api/users/{userId}': {
get: {
summary: 'Get a user by id',
parameters: [userIdPathParameter],
responses: {
'200': {
description: 'User details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/User' }
}
}
},
'404': {
description: 'User not found',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }
}
}
}
},
'/api/attachments': {
get: {
summary: 'List all attachments stored on this device',
responses: {
'200': {
description: 'Attachments array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } }
}
}
}
}
}
},
'/api/plugin-data': {
get: {
summary: 'Read a plugin data value',
parameters: [
{ name: 'pluginId', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'key', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'scope', in: 'query', required: true, schema: { type: 'string', enum: ['local', 'server'] } },
{ name: 'serverId', in: 'query', required: false, schema: { type: 'string' } }
],
responses: {
'200': {
description: 'Plugin data value',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/PluginDataValue' }
}
}
}
}
}
},
'/api/meta/{key}': {
get: {
summary: 'Read a desktop metadata value',
parameters: [{ name: 'key', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Metadata value',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/MetaValue' }
}
}
}
}
}
}
}
};

View File

@@ -104,6 +104,37 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n
return Math.max(min, Math.min(max, Math.floor(parsed)));
}
function getTrailingPathParam(pathname: string, pattern: RegExp, name: string): string {
const value = pattern.exec(pathname)?.[1];
if (!value) {
throw new HttpError(400, `${name} is required`, 'INVALID_REQUEST');
}
return decodeURIComponent(value);
}
function getRequiredQueryParam(ctx: RouteContext, name: string): string {
const value = ctx.request.url.searchParams.get(name)?.trim() ?? '';
if (!value) {
throw new HttpError(400, `${name} is required`, 'INVALID_REQUEST');
}
return value;
}
function getRequiredTimestamp(ctx: RouteContext, name: string): number {
const raw = getRequiredQueryParam(ctx, name);
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
throw new HttpError(400, `${name} must be a non-negative timestamp`, 'INVALID_REQUEST');
}
return Math.floor(value);
}
const ROUTES: RouteDefinition[] = [
defineRoute('GET', '/api/health', async (ctx): Promise<RouteResponse> => ({
status: 200,
@@ -276,20 +307,165 @@ const ROUTES: RouteDefinition[] = [
return { status: 200, body: rooms ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)$/u, 'roomId');
const room = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetRoom,
payload: { roomId }
});
if (!room) {
throw new HttpError(404, 'Room not found on this device', 'ROOM_NOT_FOUND');
}
return { status: 200, body: room };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/users', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/users$/u, 'roomId');
const users = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetUsersByRoom,
payload: { roomId }
});
return { status: 200, body: users ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/messages', async (ctx): Promise<RouteResponse> => {
const roomId = ctx.request.url.pathname.match(/\/api\/rooms\/([^/]+)\/messages$/u)?.[1];
if (!roomId)
throw new HttpError(400, 'roomId is required', 'INVALID_REQUEST');
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/messages$/u, 'roomId');
const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100);
const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0);
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMessages,
payload: { roomId: decodeURIComponent(roomId), limit, offset }
payload: { roomId, limit, offset }
});
return { status: 200, body: messages ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/messages/since', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/messages\/since$/u, 'roomId');
const sinceTimestamp = getRequiredTimestamp(ctx, 'sinceTimestamp');
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMessagesSince,
payload: { roomId, sinceTimestamp }
});
return { status: 200, body: messages ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/bans', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/bans$/u, 'roomId');
const bans = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetBansForRoom,
payload: { roomId }
});
return { status: 200, body: bans ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/bans/{userId}', async (ctx): Promise<RouteResponse> => {
const match = /\/api\/rooms\/([^/]+)\/bans\/([^/]+)$/u.exec(ctx.request.pathname);
if (!match) {
throw new HttpError(400, 'roomId and userId are required', 'INVALID_REQUEST');
}
const isBanned = await runQuery<boolean>(requireDataSource(ctx.dataSource), {
type: QueryType.IsUserBanned,
payload: { roomId: decodeURIComponent(match[1]), userId: decodeURIComponent(match[2]) }
});
return { status: 200, body: { isBanned } };
}, true),
defineRoute('GET', '/api/messages/{messageId}', async (ctx): Promise<RouteResponse> => {
const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)$/u, 'messageId');
const message = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMessageById,
payload: { messageId }
});
if (!message) {
throw new HttpError(404, 'Message not found on this device', 'MESSAGE_NOT_FOUND');
}
return { status: 200, body: message };
}, true),
defineRoute('GET', '/api/messages/{messageId}/reactions', async (ctx): Promise<RouteResponse> => {
const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)\/reactions$/u, 'messageId');
const reactions = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetReactionsForMessage,
payload: { messageId }
});
return { status: 200, body: reactions ?? [] };
}, true),
defineRoute('GET', '/api/messages/{messageId}/attachments', async (ctx): Promise<RouteResponse> => {
const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)\/attachments$/u, 'messageId');
const attachments = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetAttachmentsForMessage,
payload: { messageId }
});
return { status: 200, body: attachments ?? [] };
}, true),
defineRoute('GET', '/api/users/{userId}', async (ctx): Promise<RouteResponse> => {
const userId = getTrailingPathParam(ctx.request.pathname, /\/api\/users\/([^/]+)$/u, 'userId');
const user = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetUser,
payload: { userId }
});
if (!user) {
throw new HttpError(404, 'User not found on this device', 'USER_NOT_FOUND');
}
return { status: 200, body: user };
}, true),
defineRoute('GET', '/api/attachments', async (ctx): Promise<RouteResponse> => {
const attachments = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetAllAttachments,
payload: {}
});
return { status: 200, body: attachments ?? [] };
}, true),
defineRoute('GET', '/api/plugin-data', async (ctx): Promise<RouteResponse> => {
const pluginId = getRequiredQueryParam(ctx, 'pluginId');
const key = getRequiredQueryParam(ctx, 'key');
const scope = getRequiredQueryParam(ctx, 'scope');
if (scope !== 'local' && scope !== 'server') {
throw new HttpError(400, 'scope must be local or server', 'INVALID_REQUEST');
}
const value = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetPluginData,
payload: {
key,
pluginId,
scope,
serverId: ctx.request.url.searchParams.get('serverId') ?? undefined
}
});
return { status: 200, body: { value } };
}, true),
defineRoute('GET', '/api/meta/{key}', async (ctx): Promise<RouteResponse> => {
const key = getTrailingPathParam(ctx.request.pathname, /\/api\/meta\/([^/]+)$/u, 'key');
const value = await runQuery<string | null>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMeta,
payload: { key }
});
return { status: 200, body: { key, value } };
}, true)
];