Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement API of timeline #239

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { serve } from '@hono/node-server';
import { apiReference } from '@scalar/hono-api-reference';
import { Hono } from 'hono';
// installs polyfill into global
import * as ihp from 'iterator-helpers-polyfill';

import { accounts } from './pkg/accounts/mod.js';
import { noteHandlers } from './pkg/notes/mod.js';

ihp.installIntoGlobal();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(実際にPR出すときはこれ入れた理由をお願いしますね)


const app = new Hono();

/*
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@scalar/hono-api-reference": "^0.4.0",
"argon2": "^0.40.0",
"hono": "^3.12.10",
"iterator-helpers-polyfill": "^2.3.3",
"jose": "^5.2.1",
"prisma": "^5.9.1",
"typescript": "^5.3.3"
Expand All @@ -53,8 +54,8 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vitest": "^0.3.21",
"globals": "^15.0.0",
"glob": "^10.3.10",
"globals": "^15.0.0",
"ignore": "^5.3.1",
"kleur": "^4.1.5",
"lefthook": "^1.6.1",
Expand Down
41 changes: 41 additions & 0 deletions pkg/accounts/service/fetchAccountFollow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Option } from '@mikuroxina/mini-fn';

import type { AccountID, AccountName } from '../../accounts/model/account.js';
import type { ID } from '../../id/type.js';
import type {
AccountFollowRepository,
AccountRepository,
} from '../model/repository.js';

export class FetchAccountFollowService {
constructor(
private readonly accountFollowRepository: AccountFollowRepository,
private readonly accountRepository: AccountRepository,
) {}

async fetchFollowsByID(id: ID<AccountID>) /* inferred */ {
return this.accountFollowRepository.fetchAllFollowers(id);
}

async fetchFollowsByName(name: AccountName) /* inferred */ {
const id = await this.accountRepository
.findByName(name)
.then((o) => Option.unwrap(o))
.then((a) => a.getID());

return this.fetchFollowsByID(id);
}

async fetchFollowersByID(id: ID<AccountID>) /* inferred */ {
return this.accountFollowRepository.fetchAllFollowing(id);
}

async fetchFollowersByName(name: AccountName) /* inferred */ {
const id = await this.accountRepository
.findByName(name)
.then((o) => Option.unwrap(o))
.then((a) => a.getID());

return this.fetchFollowersByID(id);
}
}
51 changes: 50 additions & 1 deletion pkg/notes/adaptor/repository/dummy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Option, Result } from '@mikuroxina/mini-fn';
import type { AccountID } from '../../../accounts/model/account.js';
import type { ID } from '../../../id/type.js';
import { type Note, type NoteID } from '../../model/note.js';
import type { NoteRepository } from '../../model/repository.js';
import type { NoteRepository, NoteFilter } from '../../model/repository.js';

const NOTES_LIMIT = /* hard-coded */ 20;

export class InMemoryNoteRepository implements NoteRepository {
private readonly notes: Map<ID<NoteID>, Note>;
Expand All @@ -17,6 +19,53 @@ export class InMemoryNoteRepository implements NoteRepository {
return Result.ok(undefined);
}

async getFiltered(
filters: NoteFilter[],
): Promise<Result.Result<Error, Note[]>> {
let notes = this.notes.values();
for (const f of filters) {
switch (f.type) {
case 'author':
notes = notes.filter((n) => {
return f.any.find((v) => v === n.getAuthorID()) !== undefined;
});
break;

case 'attachment':
notes = notes.filter(() => {
return /* hard-coded */ 0 > f.more;
});
break;

case 'cw':
notes = notes.filter((n) => {
return n.getCwComment() === f.is;
});
break;

case 'created':
notes = notes.filter((n) => {
return n.getCreatedAt() < f.less;
});
break;

case 'updated':
notes = notes.filter((n) => {
return Option.map((v: Date) => v < f.less)(n.getUpdatedAt());
});
break;

case 'deleted':
notes = notes.filter((n) => {
return Option.isSome(n.getDeletedAt()) === f.has;
});
break;
}
}

return Result.ok(notes.take(NOTES_LIMIT).toArray());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

時刻でソートしてからLimitしたほうが良い気がします

}

async deleteByID(id: ID<NoteID>): Promise<Result.Result<Error, void>> {
const target = await this.findByID(id);
if (Option.isNone(target)) {
Expand Down
9 changes: 9 additions & 0 deletions pkg/notes/model/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@ import type { Note, NoteID } from './note.js';

export interface NoteRepository {
create(note: Note): Promise<Result.Result<Error, void>>;
getFiltered(filters: NoteFilter[]): Promise<Result.Result<Error, Note[]>>;
findByAuthorID(
authorID: ID<AccountID>,
limit: number,
): Promise<Option.Option<Note[]>>;
findByID(id: ID<NoteID>): Promise<Option.Option<Note>>;
deleteByID(id: ID<NoteID>): Promise<Result.Result<Error, void>>;
}

export type NoteFilter =
| { type: 'author'; any: ID<AccountID>[] }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

複数のユーザーの投稿をフィルターしたいユースケースが浮かなばなかったのですが、そういうユースケースがあるんでしょうか?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここでは投稿者で投稿をフィルタしているので, タイムラインに流す投稿は 指定したアカウントがフォローしているアカウント全ての投稿 に絞る, という手法を取っています. 指定したアカウントとは, つまり /timeline/home では認証情報の主であり, /timeline/accounts/:spec では :spec が該当し, /timeline/global は フィルタ無し (全投稿者による投稿の集合) となります.

| { type: 'attachment'; more: number }
| { type: 'cw'; is: string }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note.ContentsWarningCommentはあればContentsWarning扱いされるものなので、この書き方だとCWCommentの完全一致検索になってしまって意図しない挙動になるかと

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

主題から少し逸れますが, "contents warning comment" はどのように表記するのが正解なのでしょうか? CwComment のような略も存在していたと思いますが, ここでは "CW(C)" と頭文字まで切り詰めています. というかそもそも少し長すぎやしませんか?より簡潔な表現があると良いような…

| { type: 'created'; less: Date }
| { type: 'updated'; less: Date }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updatedはタイムラインでは必要ないかと.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

如何なるタイムラインでも更新日時は並び順に一切考慮されない (考慮されるのは投稿日時のみ) ということで良いですよね?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 現時点ではそうです

| { type: 'deleted'; has: boolean };
16 changes: 15 additions & 1 deletion pkg/notes/service/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import { Option, Result } from '@mikuroxina/mini-fn';

import type { AccountID } from '../../accounts/model/account.js';
import type { ID } from '../../id/type.js';
import type { AccountModule } from '../../intermodule/account.js';
import { type Note, type NoteID } from '../model/note.js';
import type { NoteRepository } from '../model/repository.js';
import type { NoteRepository, NoteFilter } from '../model/repository.js';

export type Filter =
| { type: 'user'; any: ID<AccountID>[] }
| { type: 'attachment'; has: boolean }
| { type: 'nsfw'; is: boolean }
| { type: 'before'; less: Date }
| { type: 'deleted'; is: boolean };

export class FetchNoteService {
constructor(
private readonly noteRepository: NoteRepository,
private readonly accountModule: AccountModule,
) {}

async fetchNotesWithFilters(
filters: NoteFilter[],
): Promise<Result.Result<Error, Note[]>> {
return this.noteRepository.getFiltered(filters);
}

async fetchNoteByID(noteID: ID<NoteID>): Promise<Option.Option<Note>> {
const note = await this.noteRepository.findByID(noteID);
if (Option.isNone(note)) {
Expand Down
122 changes: 122 additions & 0 deletions pkg/timeline/adaptor/controller/timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { z } from '@hono/zod-openapi';
import { Option, Result } from '@mikuroxina/mini-fn';

import type {
AccountID,
AccountName,
} from '../../../accounts/model/account.js';
import type { FetchAccountService } from '../../../accounts/service/fetchAccount.js';
import type { FetchAccountFollowService } from '../../../accounts/service/fetchAccountFollow.js';
import type { ID } from '../../../id/type.js';
import type { NoteFilter } from '../../../notes/model/repository.js';
import type { FetchNoteService } from '../../../notes/service/fetch.js';
import type { NoteSchema } from '../validator/schema.js';

export class TimelineController {
constructor(
private readonly fetchAccountService: FetchAccountService,
private readonly fetchAccountFollowService: FetchAccountFollowService,
private readonly fetchNoteService: FetchNoteService,
Comment on lines +17 to +19
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これらはintermoduleモジュールからimportしたものを使ってください

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AccountModule は既にあるものを (必要に応じて) 拡張するとして, NoteModule は別で立てる必要がありますね?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

そうしてください

) {}

async getTimeline({
target,
hasAttachment,
isNsfw,
beforeID,
}: {
target?: string;
hasAttachment?: boolean;
isNsfw?: boolean;
beforeID?: string;
}): Promise<Result.Result<Error, z.infer<typeof NoteSchema>[]>> {
const filters: NoteFilter[] = [];

if (target !== undefined) {
const resFollows = isID<AccountID>(target)
? await this.fetchAccountFollowService.fetchFollowsByID(target)
: isAccountName(target)
? await this.fetchAccountFollowService.fetchFollowsByName(target)
: panic();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throwするよりResult.errを返した方が良いかと


if (Result.isErr(resFollows)) {
return resFollows;
}

filters.push({
type: 'author',
any: resFollows[1].map((af) => af.getTargetID()),
});
}

if (hasAttachment !== undefined) {
filters.push({ type: 'attachment', more: hasAttachment ? 0 : -1 });
}

if (isNsfw !== undefined) {
filters.push({ type: 'cw', is: 'nsfw' });
}

if (beforeID !== undefined && isID<AccountID>(beforeID)) {
const beforeNote = await this.fetchNoteService
.fetchNoteByID(beforeID)
.then((o) => Option.unwrap(o));

filters.push({ type: 'created', less: beforeNote.getCreatedAt() });
}

const resNotes = await this.fetchNoteService.fetchNotesWithFilters(filters);
if (Result.isErr(resNotes)) {
return resNotes;
}

const notes = [];
for (const n of resNotes[1]) {
const resAccount = await this.fetchAccountService.fetchAccountByID(
n.getAuthorID(),
);

if (Result.isErr(resAccount)) {
return resAccount;
}

const a = resAccount[1];

notes.push({
id: n.getID(),
content: n.getContent(),
contents_warning_comment: n.getCwComment(),
send_to: n.getSendTo()[1],
visibility: n.getVisibility(),
created_at: n.getCreatedAt().toUTCString(),
author: {
id: a.getID(),
name: a.getName(),
display_name: a.getNickname(),
bio: a.getBio(),
avatar: '',
header: '',
followed_count: 0,
following_count: 0,
},
});
}

return Result.ok(notes);
}
}

const isID = <T>(s: string): s is ID<T> => {
return z
.string()
.regex(/^\d{64}$/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulsate-SnowflakeIDは固定長ではないのでこれは落ちると思います

Copy link
Member Author

@nanai10a nanai10a Mar 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zod によるパーサ (少なくとも型判定ができるもの) で少なくとも ID<T>AccountName についてのパーサが欲しいのですが, 既知の実装か formal な規格が存在しますか?

Copy link
Member

@laminne laminne Mar 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

どちらも既知の実装はありません
仕様に関してはIDはreadmeに書いてあります

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ID<T> に関して了解しました. AccountName に関して該当する仕様が見つけられなかったのですが, ご教示願えますでしょうか?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notionに記載があります

.safeParse(s).success;
};

const isAccountName = (s: string): s is AccountName => {
return /@\w+@\w+/.test(s);
};

const panic = () => {
throw new Error('panic!');
};
23 changes: 23 additions & 0 deletions pkg/timeline/adaptor/validator/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from '@hono/zod-openapi';

// TODO: もっとどうにかならんのか
import { GetNoteResponseSchema } from '../../../notes/adaptor/validator/schema.js';

export const NoteSchema = GetNoteResponseSchema;

// TODO: 逃げてる
export const CommonErrorResponseSchema = z.object({
error: z
.enum([
'INVALID_TIMELINE_TYPE',
'YOU_ARE_BLOCKED',
'NOTHING_LEFT',
'ACCOUNT_NOT_FOUND',
])
.or(z.string())
.openapi({
description: 'Error code',
}),
});

export const GetTimelineResponseSchema = NoteSchema.array();
Loading
Loading