mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-07-07 12:36:57 +00:00
support Mastodon v4 "link header" pagination
This commit is contained in:
parent
3d8930f070
commit
fc1d0c958c
6 changed files with 323 additions and 84 deletions
|
@ -51,12 +51,14 @@ export class MastodonClientService {
|
||||||
return new Misskey(baseUrl, accessToken, userAgent);
|
return new Misskey(baseUrl, accessToken, userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
readonly getBaseUrl = getBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Gets the base URL (origin) of the incoming request
|
* Gets the base URL (origin) of the incoming request
|
||||||
*/
|
*/
|
||||||
public getBaseUrl(request: FastifyRequest): string {
|
export function getBaseUrl(request: FastifyRequest): string {
|
||||||
return `${request.protocol}://${request.host}`;
|
return `${request.protocol}://${request.host}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { MastodonClientService } from '@/server/api/mastodon/MastodonClientServi
|
||||||
import { DriveService } from '@/core/DriveService.js';
|
import { DriveService } from '@/core/DriveService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js';
|
import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js';
|
||||||
|
import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
|
||||||
import { MastoConverters, convertRelationship, convertFeaturedTag, convertList } from '../converters.js';
|
import { MastoConverters, convertRelationship, convertFeaturedTag, convertList } from '../converters.js';
|
||||||
import type multer from 'fastify-multer';
|
import type multer from 'fastify-multer';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
@ -173,14 +174,15 @@ export class ApiAccountMastodon {
|
||||||
reply.send(account);
|
reply.send(account);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => {
|
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/statuses', async (request, reply) => {
|
||||||
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
||||||
|
|
||||||
const { client, me } = await this.clientService.getAuthClient(_request);
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
const args = parseTimelineArgs(_request.query);
|
const args = parseTimelineArgs(request.query);
|
||||||
const data = await client.getAccountStatuses(_request.params.id, args);
|
const data = await client.getAccountStatuses(request.params.id, args);
|
||||||
const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me)));
|
const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me)));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -194,29 +196,31 @@ export class ApiAccountMastodon {
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/followers', async (_request, reply) => {
|
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/followers', async (request, reply) => {
|
||||||
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
||||||
|
|
||||||
const client = this.clientService.getClient(_request);
|
const client = this.clientService.getClient(request);
|
||||||
const data = await client.getAccountFollowers(
|
const data = await client.getAccountFollowers(
|
||||||
_request.params.id,
|
request.params.id,
|
||||||
parseTimelineArgs(_request.query),
|
parseTimelineArgs(request.query),
|
||||||
);
|
);
|
||||||
const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
|
const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/following', async (_request, reply) => {
|
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/following', async (request, reply) => {
|
||||||
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
||||||
|
|
||||||
const client = this.clientService.getClient(_request);
|
const client = this.clientService.getClient(request);
|
||||||
const data = await client.getAccountFollowing(
|
const data = await client.getAccountFollowing(
|
||||||
_request.params.id,
|
request.params.id,
|
||||||
parseTimelineArgs(_request.query),
|
parseTimelineArgs(request.query),
|
||||||
);
|
);
|
||||||
const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
|
const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -236,7 +240,7 @@ export class ApiAccountMastodon {
|
||||||
const client = this.clientService.getClient(_request);
|
const client = this.clientService.getClient(_request);
|
||||||
const data = await client.followAccount(_request.params.id);
|
const data = await client.followAccount(_request.params.id);
|
||||||
const acct = convertRelationship(data.data);
|
const acct = convertRelationship(data.data);
|
||||||
acct.following = true;
|
acct.following = true; // TODO this is wrong, follow may not have processed immediately
|
||||||
|
|
||||||
reply.send(acct);
|
reply.send(acct);
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js';
|
import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js';
|
||||||
import { MastoConverters } from '@/server/api/mastodon/converters.js';
|
import { MastoConverters } from '@/server/api/mastodon/converters.js';
|
||||||
|
import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
|
||||||
import { MastodonClientService } from '../MastodonClientService.js';
|
import { MastodonClientService } from '../MastodonClientService.js';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type multer from 'fastify-multer';
|
import type multer from 'fastify-multer';
|
||||||
|
@ -25,9 +26,9 @@ export class ApiNotificationsMastodon {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
|
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
|
||||||
fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (_request, reply) => {
|
fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (request, reply) => {
|
||||||
const { client, me } = await this.clientService.getAuthClient(_request);
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
const data = await client.getNotifications(parseTimelineArgs(_request.query));
|
const data = await client.getNotifications(parseTimelineArgs(request.query));
|
||||||
const response = await Promise.all(data.data.map(async n => {
|
const response = await Promise.all(data.data.map(async n => {
|
||||||
const converted = await this.mastoConverters.convertNotification(n, me);
|
const converted = await this.mastoConverters.convertNotification(n, me);
|
||||||
if (converted.type === 'reaction') {
|
if (converted.type === 'reaction') {
|
||||||
|
@ -36,6 +37,7 @@ export class ApiNotificationsMastodon {
|
||||||
return converted;
|
return converted;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,16 +5,18 @@
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
|
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
|
||||||
|
import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js';
|
||||||
import { MastoConverters } from '../converters.js';
|
import { MastoConverters } from '../converters.js';
|
||||||
import { parseTimelineArgs, TimelineArgs } from '../argsUtils.js';
|
import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js';
|
||||||
import Account = Entity.Account;
|
import Account = Entity.Account;
|
||||||
import Status = Entity.Status;
|
import Status = Entity.Status;
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
interface ApiSearchMastodonRoute {
|
interface ApiSearchMastodonRoute {
|
||||||
Querystring: TimelineArgs & {
|
Querystring: TimelineArgs & {
|
||||||
type?: 'accounts' | 'hashtags' | 'statuses';
|
type?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
resolve?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,66 +28,116 @@ export class ApiSearchMastodon {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public register(fastify: FastifyInstance): void {
|
public register(fastify: FastifyInstance): void {
|
||||||
fastify.get<ApiSearchMastodonRoute>('/v1/search', async (_request, reply) => {
|
fastify.get<ApiSearchMastodonRoute>('/v1/search', async (request, reply) => {
|
||||||
if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' });
|
if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' });
|
||||||
if (!_request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' });
|
if (!request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' });
|
||||||
|
|
||||||
const query = parseTimelineArgs(_request.query);
|
const type = request.query.type;
|
||||||
const client = this.clientService.getClient(_request);
|
if (type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') {
|
||||||
const data = await client.search(_request.query.q, { type: _request.query.type, ...query });
|
return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' });
|
||||||
|
}
|
||||||
|
|
||||||
reply.send(data.data);
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
});
|
|
||||||
|
|
||||||
fastify.get<ApiSearchMastodonRoute>('/v2/search', async (_request, reply) => {
|
if (toBoolean(request.query.resolve) && !me) {
|
||||||
if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' });
|
return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' });
|
||||||
|
}
|
||||||
|
if (toInt(request.query.offset) && !me) {
|
||||||
|
return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' });
|
||||||
|
}
|
||||||
|
|
||||||
const query = parseTimelineArgs(_request.query);
|
// TODO implement resolve
|
||||||
const type = _request.query.type;
|
|
||||||
const { client, me } = await this.clientService.getAuthClient(_request);
|
const query = parseTimelineArgs(request.query);
|
||||||
const acct = !type || type === 'accounts' ? await client.search(_request.query.q, { type: 'accounts', ...query }) : null;
|
const { data } = await client.search(request.query.q, { type, ...query });
|
||||||
const stat = !type || type === 'statuses' ? await client.search(_request.query.q, { type: 'statuses', ...query }) : null;
|
|
||||||
const tags = !type || type === 'hashtags' ? await client.search(_request.query.q, { type: 'hashtags', ...query }) : null;
|
|
||||||
const response = {
|
const response = {
|
||||||
accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []),
|
...data,
|
||||||
statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, me)) ?? []),
|
accounts: await Promise.all(data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account))),
|
||||||
hashtags: tags?.data.hashtags ?? [],
|
statuses: await Promise.all(data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me))),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (type === 'hashtags') {
|
||||||
|
attachOffsetPagination(request, reply, response.hashtags);
|
||||||
|
} else {
|
||||||
|
attachMinMaxPagination(request, reply, response[type]);
|
||||||
|
}
|
||||||
|
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<ApiSearchMastodonRoute>('/v1/trends/statuses', async (_request, reply) => {
|
fastify.get<ApiSearchMastodonRoute>('/v2/search', async (request, reply) => {
|
||||||
const baseUrl = this.clientService.getBaseUrl(_request);
|
if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' });
|
||||||
|
|
||||||
|
const type = request.query.type;
|
||||||
|
if (type !== undefined && type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') {
|
||||||
|
return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
|
|
||||||
|
if (toBoolean(request.query.resolve) && !me) {
|
||||||
|
return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' });
|
||||||
|
}
|
||||||
|
if (toInt(request.query.offset) && !me) {
|
||||||
|
return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO implement resolve
|
||||||
|
|
||||||
|
const query = parseTimelineArgs(request.query);
|
||||||
|
const acct = !type || type === 'accounts' ? await client.search(request.query.q, { type: 'accounts', ...query }) : null;
|
||||||
|
const stat = !type || type === 'statuses' ? await client.search(request.query.q, { type: 'statuses', ...query }) : null;
|
||||||
|
const tags = !type || type === 'hashtags' ? await client.search(request.query.q, { type: 'hashtags', ...query }) : null;
|
||||||
|
const response = {
|
||||||
|
accounts: await Promise.all(acct?.data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account)) ?? []),
|
||||||
|
statuses: await Promise.all(stat?.data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me)) ?? []),
|
||||||
|
hashtags: tags?.data.hashtags ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pagination hack, based on "best guess" expected behavior.
|
||||||
|
// Mastodon doesn't document this part at all!
|
||||||
|
const longestResult = [response.statuses, response.hashtags]
|
||||||
|
.reduce((longest: unknown[], current: unknown[]) => current.length > longest.length ? current : longest, response.accounts);
|
||||||
|
|
||||||
|
// Ignore min/max pagination because how TF would that work with multiple result sets??
|
||||||
|
// Offset pagination is the only possible option
|
||||||
|
attachOffsetPagination(request, reply, longestResult);
|
||||||
|
|
||||||
|
reply.send(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get<ApiSearchMastodonRoute>('/v1/trends/statuses', async (request, reply) => {
|
||||||
|
const baseUrl = this.clientService.getBaseUrl(request);
|
||||||
const res = await fetch(`${baseUrl}/api/notes/featured`,
|
const res = await fetch(`${baseUrl}/api/notes/featured`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
..._request.headers as HeadersInit,
|
...request.headers as HeadersInit,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: '{}',
|
body: '{}',
|
||||||
});
|
});
|
||||||
const data = await res.json() as Status[];
|
const data = await res.json() as Status[];
|
||||||
const me = await this.clientService.getAuth(_request);
|
const me = await this.clientService.getAuth(request);
|
||||||
const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me)));
|
const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me)));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<ApiSearchMastodonRoute>('/v2/suggestions', async (_request, reply) => {
|
fastify.get<ApiSearchMastodonRoute>('/v2/suggestions', async (request, reply) => {
|
||||||
const baseUrl = this.clientService.getBaseUrl(_request);
|
const baseUrl = this.clientService.getBaseUrl(request);
|
||||||
const res = await fetch(`${baseUrl}/api/users`,
|
const res = await fetch(`${baseUrl}/api/users`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
..._request.headers as HeadersInit,
|
...request.headers as HeadersInit,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
limit: parseTimelineArgs(_request.query).limit ?? 20,
|
limit: parseTimelineArgs(request.query).limit ?? 20,
|
||||||
origin: 'local',
|
origin: 'local',
|
||||||
sort: '+follower',
|
sort: '+follower',
|
||||||
state: 'alive',
|
state: 'alive',
|
||||||
|
@ -99,6 +151,7 @@ export class ApiSearchMastodon {
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
attachOffsetPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
|
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
|
||||||
|
import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
|
||||||
import { convertList, MastoConverters } from '../converters.js';
|
import { convertList, MastoConverters } from '../converters.js';
|
||||||
import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js';
|
import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js';
|
||||||
import type { Entity } from 'megalodon';
|
import type { Entity } from 'megalodon';
|
||||||
|
@ -18,55 +19,60 @@ export class ApiTimelineMastodon {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public register(fastify: FastifyInstance): void {
|
public register(fastify: FastifyInstance): void {
|
||||||
fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => {
|
fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (request, reply) => {
|
||||||
const { client, me } = await this.clientService.getAuthClient(_request);
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
const query = parseTimelineArgs(_request.query);
|
const query = parseTimelineArgs(request.query);
|
||||||
const data = toBoolean(_request.query.local)
|
const data = toBoolean(request.query.local)
|
||||||
? await client.getLocalTimeline(query)
|
? await client.getLocalTimeline(query)
|
||||||
: await client.getPublicTimeline(query);
|
: await client.getPublicTimeline(query);
|
||||||
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
|
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => {
|
fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => {
|
||||||
const { client, me } = await this.clientService.getAuthClient(_request);
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
const query = parseTimelineArgs(_request.query);
|
const query = parseTimelineArgs(request.query);
|
||||||
const data = await client.getHomeTimeline(query);
|
const data = await client.getHomeTimeline(query);
|
||||||
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
|
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => {
|
fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => {
|
||||||
if (!_request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' });
|
if (!request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' });
|
||||||
|
|
||||||
const { client, me } = await this.clientService.getAuthClient(_request);
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
const query = parseTimelineArgs(_request.query);
|
const query = parseTimelineArgs(request.query);
|
||||||
const data = await client.getTagTimeline(_request.params.hashtag, query);
|
const data = await client.getTagTimeline(request.params.hashtag, query);
|
||||||
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
|
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => {
|
fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => {
|
||||||
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
||||||
|
|
||||||
const { client, me } = await this.clientService.getAuthClient(_request);
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
const query = parseTimelineArgs(_request.query);
|
const query = parseTimelineArgs(request.query);
|
||||||
const data = await client.getListTimeline(_request.params.id, query);
|
const data = await client.getListTimeline(request.params.id, query);
|
||||||
const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)));
|
const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => {
|
fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => {
|
||||||
const { client, me } = await this.clientService.getAuthClient(_request);
|
const { client, me } = await this.clientService.getAuthClient(request);
|
||||||
const query = parseTimelineArgs(_request.query);
|
const query = parseTimelineArgs(request.query);
|
||||||
const data = await client.getConversationTimeline(query);
|
const data = await client.getConversationTimeline(query);
|
||||||
const conversations = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me)));
|
const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me)));
|
||||||
|
|
||||||
reply.send(conversations);
|
attachMinMaxPagination(request, reply, response);
|
||||||
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
|
fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
|
||||||
|
@ -79,22 +85,24 @@ export class ApiTimelineMastodon {
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get('/v1/lists', async (_request, reply) => {
|
fastify.get('/v1/lists', async (request, reply) => {
|
||||||
const client = this.clientService.getClient(_request);
|
const client = this.clientService.getClient(request);
|
||||||
const data = await client.getLists();
|
const data = await client.getLists();
|
||||||
const response = data.data.map((list: Entity.List) => convertList(list));
|
const response = data.data.map((list: Entity.List) => convertList(list));
|
||||||
|
|
||||||
|
attachMinMaxPagination(request, reply, response);
|
||||||
reply.send(response);
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
|
fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => {
|
||||||
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
|
||||||
|
|
||||||
const client = this.clientService.getClient(_request);
|
const client = this.clientService.getClient(request);
|
||||||
const data = await client.getAccountsInList(_request.params.id, _request.query);
|
const data = await client.getAccountsInList(request.params.id, parseTimelineArgs(request.query));
|
||||||
const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
|
const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
|
||||||
|
|
||||||
reply.send(accounts);
|
attachMinMaxPagination(request, reply, response);
|
||||||
|
reply.send(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
|
fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
|
||||||
|
|
170
packages/backend/src/server/api/mastodon/pagination.ts
Normal file
170
packages/backend/src/server/api/mastodon/pagination.ts
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js';
|
||||||
|
|
||||||
|
interface AnyEntity {
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches Mastodon's pagination headers to a response that is paginated by min_id / max_id parameters.
|
||||||
|
* Results must be sorted, but can be in ascending or descending order.
|
||||||
|
* Attached headers will always be in descending order.
|
||||||
|
*
|
||||||
|
* @param request Fastify request object
|
||||||
|
* @param reply Fastify reply object
|
||||||
|
* @param results Results array, ordered in ascending or descending order
|
||||||
|
*/
|
||||||
|
export function attachMinMaxPagination(request: FastifyRequest, reply: FastifyReply, results: AnyEntity[]): void {
|
||||||
|
// No results, nothing to do
|
||||||
|
if (!hasItems(results)) return;
|
||||||
|
|
||||||
|
// "next" link - older results
|
||||||
|
const oldest = findOldest(results);
|
||||||
|
const nextUrl = createPaginationUrl(request, { max_id: oldest }); // Next page (older) has IDs less than the oldest of this page
|
||||||
|
const next = `<${nextUrl}>; rel="next"`;
|
||||||
|
|
||||||
|
// "prev" link - newer results
|
||||||
|
const newest = findNewest(results);
|
||||||
|
const prevUrl = createPaginationUrl(request, { min_id: newest }); // Previous page (newer) has IDs greater than the newest of this page
|
||||||
|
const prev = `<${prevUrl}>; rel="prev"`;
|
||||||
|
|
||||||
|
// https://docs.joinmastodon.org/api/guidelines/#pagination
|
||||||
|
const link = `${next}, ${prev}`;
|
||||||
|
reply.header('link', link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches Mastodon's pagination headers to a response that is paginated by limit / offset parameters.
|
||||||
|
* Results must be sorted, but can be in ascending or descending order.
|
||||||
|
* Attached headers will always be in descending order.
|
||||||
|
*
|
||||||
|
* @param request Fastify request object
|
||||||
|
* @param reply Fastify reply object
|
||||||
|
* @param results Results array, ordered in ascending or descending order
|
||||||
|
*/
|
||||||
|
export function attachOffsetPagination(request: FastifyRequest, reply: FastifyReply, results: unknown[]): void {
|
||||||
|
const links: string[] = [];
|
||||||
|
|
||||||
|
// Find initial offset
|
||||||
|
const offset = findOffset(request);
|
||||||
|
const limit = findLimit(request);
|
||||||
|
|
||||||
|
// "next" link - older results
|
||||||
|
if (hasItems(results)) {
|
||||||
|
const oldest = offset + results.length;
|
||||||
|
const nextUrl = createPaginationUrl(request, { offset: oldest }); // Next page (older) has entries less than the oldest of this page
|
||||||
|
links.push(`<${nextUrl}>; rel="next"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "prev" link - newer results
|
||||||
|
// We can only paginate backwards if a limit is specified
|
||||||
|
if (limit) {
|
||||||
|
// Make sure we don't cross below 0, as that will produce an API error
|
||||||
|
if (limit <= offset) {
|
||||||
|
const newest = offset - limit;
|
||||||
|
const prevUrl = createPaginationUrl(request, { offset: newest }); // Previous page (newer) has entries greater than the newest of this page
|
||||||
|
links.push(`<${prevUrl}>; rel="prev"`);
|
||||||
|
} else {
|
||||||
|
const prevUrl = createPaginationUrl(request, { offset: 0, limit: offset }); // Previous page (newer) has entries greater than the newest of this page
|
||||||
|
links.push(`<${prevUrl}>; rel="prev"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://docs.joinmastodon.org/api/guidelines/#pagination
|
||||||
|
if (links.length > 0) {
|
||||||
|
const link = links.join(', ');
|
||||||
|
reply.header('link', link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasItems<T>(items: T[]): items is [T, ...T[]] {
|
||||||
|
return items.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOffset(request: FastifyRequest): number {
|
||||||
|
if (typeof(request.query) !== 'object') return 0;
|
||||||
|
|
||||||
|
const query = request.query as Record<string, string | string[] | undefined>;
|
||||||
|
if (!query.offset) return 0;
|
||||||
|
|
||||||
|
if (Array.isArray(query.offset)) {
|
||||||
|
const offsets = query.offset
|
||||||
|
.map(o => parseInt(o))
|
||||||
|
.filter(o => !isNaN(o));
|
||||||
|
const offset = Math.max(...offsets);
|
||||||
|
return isNaN(offset) ? 0 : offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = parseInt(query.offset);
|
||||||
|
return isNaN(offset) ? 0 : offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLimit(request: FastifyRequest): number | null {
|
||||||
|
if (typeof(request.query) !== 'object') return null;
|
||||||
|
|
||||||
|
const query = request.query as Record<string, string | string[] | undefined>;
|
||||||
|
if (!query.limit) return null;
|
||||||
|
|
||||||
|
if (Array.isArray(query.limit)) {
|
||||||
|
const limits = query.limit
|
||||||
|
.map(l => parseInt(l))
|
||||||
|
.filter(l => !isNaN(l));
|
||||||
|
const limit = Math.max(...limits);
|
||||||
|
return isNaN(limit) ? null : limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = parseInt(query.limit);
|
||||||
|
return isNaN(limit) ? null : limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOldest(items: [AnyEntity, ...AnyEntity[]]): string {
|
||||||
|
const first = items[0].id;
|
||||||
|
const last = items[items.length - 1].id;
|
||||||
|
|
||||||
|
return isOlder(first, last) ? first : last;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNewest(items: [AnyEntity, ...AnyEntity[]]): string {
|
||||||
|
const first = items[0].id;
|
||||||
|
const last = items[items.length - 1].id;
|
||||||
|
|
||||||
|
return isOlder(first, last) ? last : first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOlder(a: string, b: string): boolean {
|
||||||
|
if (a === b) return false;
|
||||||
|
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return a.length < b.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a < b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPaginationUrl(request: FastifyRequest, data: {
|
||||||
|
min_id?: string;
|
||||||
|
max_id?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): string {
|
||||||
|
const baseUrl = getBaseUrl(request);
|
||||||
|
const requestUrl = new URL(request.url, baseUrl);
|
||||||
|
|
||||||
|
// Remove any existing pagination
|
||||||
|
requestUrl.searchParams.delete('min_id');
|
||||||
|
requestUrl.searchParams.delete('max_id');
|
||||||
|
requestUrl.searchParams.delete('since_id');
|
||||||
|
requestUrl.searchParams.delete('offset');
|
||||||
|
|
||||||
|
if (data.min_id) requestUrl.searchParams.set('min_id', data.min_id);
|
||||||
|
if (data.max_id) requestUrl.searchParams.set('max_id', data.max_id);
|
||||||
|
if (data.offset) requestUrl.searchParams.set('offset', String(data.offset));
|
||||||
|
if (data.limit) requestUrl.searchParams.set('limit', String(data.limit));
|
||||||
|
|
||||||
|
return requestUrl.href;
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue