diff --git a/api/birdxplorer_api/openapi_doc.py b/api/birdxplorer_api/openapi_doc.py new file mode 100644 index 0000000..c520f36 --- /dev/null +++ b/api/birdxplorer_api/openapi_doc.py @@ -0,0 +1,481 @@ +from dataclasses import dataclass +from typing import Dict, Generic + +from fastapi.openapi.models import Example +from typing_extensions import LiteralString, TypedDict, TypeVar + +from birdxplorer_common.models import LanguageIdentifier, NoteId, PostId + + +class FastAPIEndpointQueryDocsRequired(TypedDict): + description: str + + +class FastAPIEndpointParamDocs(FastAPIEndpointQueryDocsRequired, total=False): + openapi_examples: Dict[str, Example] + + +_KEY = TypeVar("_KEY", bound=LiteralString) + + +@dataclass +class FastAPIEndpointDocs(Generic[_KEY]): + """ + FastAPI のエンドポイントのドキュメントをまとめた dataclass。 + """ + + description: str + params: Dict[_KEY, FastAPIEndpointParamDocs] + + +SAMPLE_POST_IDS = [PostId("1828261879854309500"), PostId("1828261879854309501")] +SAMPLE_NOTE_IDS = [NoteId("1845672983001710655"), NoteId("1845776988935770187")] + +v1_data_posts_post_id: FastAPIEndpointParamDocs = { + "description": """ +データを取得する X の Post の ID。 + +複数回クエリパラメータを指定する / カンマ区切りで複数の ID を指定することで複数の Post 一括で取得できる。 + +--- + +なお、Post の ID は Post の URL から確認できる。 + +| Post の URL | Post の ID | +| :---------------------------------------------------: | :-----------------: | +| https://x.com/CodeforJapan/status/1828261879854309500 | 1828261879854309500 | +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "single": { + "summary": "Post を 1つ取得する", + "value": [SAMPLE_POST_IDS[0]], + }, + "multiple_query": { + "summary": "Post を複数取得する (クエリパラメータ)", + "value": SAMPLE_POST_IDS, + }, + "multiple_comma": { + "summary": "Post を複数取得する (カンマ区切り)", + "value": [",".join(SAMPLE_POST_IDS)], + }, + }, +} + +v1_data_posts_note_id: FastAPIEndpointParamDocs = { + "description": """ +Post のデータ取得に利用する X のコミュニティノートの ID。 +コミュニティノートと Post は 1 : 1 で紐づいている。 + +複数回クエリパラメータを指定する / カンマ区切りで複数の ID を指定することで複数の Post を一括で取得できる。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "single": { + "summary": "コミュニティノートに紐づいた Post を 1つ取得する", + "value": [SAMPLE_NOTE_IDS[0]], + }, + "multiple_query": { + "summary": "複数のコミュニティノートについて、それぞれに紐づいた Post を取得する (クエリパラメータ)", + "value": SAMPLE_NOTE_IDS, + }, + "multiple_comma": { + "summary": "複数のコミュニティノートについて、それぞれに紐づいた Post を取得する (カンマ区切り)", + "value": [",".join(SAMPLE_NOTE_IDS)], + }, + }, +} + +v1_data_posts_created_at_from: FastAPIEndpointParamDocs = { + "description": """ +取得する Post の作成日時の下限。**指定した日時と同時かそれより新しい** Post のみを取得する。 + +指定する形式は UNIX EPOCH TIME (ミリ秒) 。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "normal": { + "summary": "2024 / 1 / 1 00:00 (JST) 以降の Post を取得する", + "value": 1704034800000, + }, + }, +} + +v1_data_posts_created_at_to: FastAPIEndpointParamDocs = { + "description": """ +取得する Post の作成日時の上限。**指定した日時よりも古い** Post のみを取得する。 + +指定する形式は UNIX EPOCH TIME (ミリ秒) 。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "normal": { + "summary": "2024 / 7 / 1 00:00 (JST) より前の Post を取得する", + "value": 1719759600000, + }, + }, +} + +v1_data_posts_offset: FastAPIEndpointParamDocs = { + "description": """ +取得する Post のリストの先頭からのオフセット。ページネーションに利用される。 + + +ただし、レスポンスの `meta.next` や `meta.prev` で次のページや前のページのリクエスト用 URL が提供されるため、 +そちらを利用したほうが良い場合もある。 +""", + "openapi_examples": { + "default": { + "summary": "0 (デフォルト)", + "value": 0, + }, + "offset_100": { + "summary": "100", + "value": 100, + }, + }, +} + +v1_data_posts_limit: FastAPIEndpointParamDocs = { + "description": """ +取得する Post のリストの最大数。 0 ~ 1000 まで指定できる。ページネーションに利用される。 + + +ただし、 `meta.next` や `meta.prev` で次のページや前のページのリクエスト用 URL が提供されるため、 +そちらを利用したほうが良い場合もある。 +""", + "openapi_examples": { + "default": { + "summary": "100 (デフォルト)", + "value": 100, + }, + "limit_50": { + "summary": "50", + "value": 50, + }, + }, +} + +v1_data_posts_search_text: FastAPIEndpointParamDocs = { + "description": """ +指定した文字列を含む Post を検索して取得する。検索は Post の**本文に対して**行われる。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "python": { + "summary": "「Python」を含む Post を取得する", + "value": "Python", + }, + }, +} + +v1_data_posts_search_url: FastAPIEndpointParamDocs = { + "description": """ +指定した URL を含む Post を検索して取得する。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "example.com": { + "summary": "「https://example.com」を含む Post を取得する", + "value": "https://example.com", + }, + }, +} + +v1_data_posts_media: FastAPIEndpointParamDocs = { + "description": """ +Post に紐づいた画像や動画などのメディア情報を取得するかどうか。 + +必要に応じて `false` に設定することでメディア情報を取得しないようにできる。 +""", + "openapi_examples": { + "default": { + "summary": "メディア情報を取得する (デフォルト)", + "value": True, + }, + "no_media": { + "summary": "メディア情報を取得しない", + "value": False, + }, + }, +} + +V1DataPostsDocs = FastAPIEndpointDocs( + "Post のデータを取得するエンドポイント", + { + "post_id": v1_data_posts_post_id, + "note_id": v1_data_posts_note_id, + "created_at_from": v1_data_posts_created_at_from, + "created_at_to": v1_data_posts_created_at_to, + "offset": v1_data_posts_offset, + "limit": v1_data_posts_limit, + "search_text": v1_data_posts_search_text, + "search_url": v1_data_posts_search_url, + "media": v1_data_posts_media, + }, +) + +v1_data_notes_note_ids: FastAPIEndpointParamDocs = { + "description": """ +データを取得する X のコミュニティノートの ID。 + +複数回クエリパラメータを指定する / カンマ区切りで複数の ID を指定することで複数のコミュニティノートを一括で取得できる。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "single": { + "summary": "コミュニティノートを 1つ取得する", + "value": [SAMPLE_NOTE_IDS[0]], + }, + "multiple_query": { + "summary": "コミュニティノートを複数取得する (クエリパラメータ)", + "value": SAMPLE_NOTE_IDS, + }, + "multiple_comma": { + "summary": "コミュニティノートを複数取得する (カンマ区切り)", + "value": [",".join(SAMPLE_NOTE_IDS)], + }, + }, +} + +v1_data_notes_created_at_from: FastAPIEndpointParamDocs = { + "description": """ +取得するコミュニティノートの作成日時の下限。**指定した日時と同時かそれより新しい**コミュニティノートのみを取得する。 + +指定する形式は UNIX EPOCH TIME (ミリ秒) 。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "normal": { + "summary": "2024 / 1 / 1 00:00 (JST) 以降のコミュニティノートを取得する", + "value": 1704034800000, + }, + }, +} + +v1_data_notes_created_at_to: FastAPIEndpointParamDocs = { + "description": """ +取得するコミュニティノートの作成日時の上限。**指定した日時よりも古い**コミュニティノートのみを取得する。 + +指定する形式は UNIX EPOCH TIME (ミリ秒) 。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "normal": { + "summary": "2024 / 7 / 1 00:00 (JST) より前のコミュニティノートを取得する", + "value": 1719759600000, + }, + }, +} + +v1_data_notes_offset: FastAPIEndpointParamDocs = { + "description": """ +取得するコミュニティノートのリストの先頭からのオフセット。ページネーションに利用される。 + + +ただし、レスポンスの `meta.next` や `meta.prev` で次のページや前のページのリクエスト用 URL が提供されるため、 +そちらを利用したほうが良い場合もある。 +""", + "openapi_examples": { + "default": { + "summary": "0 (デフォルト)", + "value": 0, + }, + "offset_100": { + "summary": "100", + "value": 100, + }, + }, +} + +v1_data_notes_limit: FastAPIEndpointParamDocs = { + "description": """ +取得するコミュニティノートのリストの最大数。 0 ~ 1000 まで指定できる。ページネーションに利用される。 + + +ただし、レスポンスの `meta.next` や `meta.prev` で次のページや前のページのリクエスト用 URL が提供されるため、 +そちらを利用したほうが良い場合もある。 +""", + "openapi_examples": { + "default": { + "summary": "100 (デフォルト)", + "value": 100, + }, + "limit_50": { + "summary": "50", + "value": 50, + }, + }, +} + +v1_date_notes_topic_ids: FastAPIEndpointParamDocs = { + "description": """ +取得するコミュニティノートが紐づいているトピックの ID。 + +`GET /api/v1/data/topics` で取得できるトピックの ID を指定することで、そのトピックに紐づいたコミュニティノートを取得できる。 + +複数指定した場合は、 **いずれかのトピックに紐づいたコミュニティノート** を取得する。 (AND 検索ではなく OR 検索になる) +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "single": { + "summary": "トピックに紐づいたコミュニティノートを取得する", + "value": [1], + }, + "multiple_query": { + "summary": "複数のトピックに紐づいたコミュニティノートを取得する (クエリパラメータ)", + "value": [1, 2], + }, + "multiple_comma": { + "summary": "複数のトピックに紐づいたコミュニティノートを取得する (カンマ区切り)", + "value": ["1,2"], + }, + }, +} + +v1_data_notes_post_ids: FastAPIEndpointParamDocs = { + "description": """ +コミュニティノートのデータ取得に利用する X の Post の ID。 +コミュニティノートと Post は 1 : 1 で紐づいている。 + +複数回クエリパラメータを指定する / カンマ区切りで複数の ID を指定することで複数のコミュニティノートを一括で取得できる。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "single": { + "summary": "Post に紐づいたコミュニティノートを 1つ取得する", + "value": [SAMPLE_POST_IDS[0]], + }, + "multiple_query": { + "summary": "複数の Post について、それぞれに紐づいたコミュニティノートを取得する (クエリパラメータ)", + "value": SAMPLE_POST_IDS, + }, + "multiple_comma": { + "summary": "複数の Post について、それぞれに紐づいたコミュニティノートを取得する (カンマ区切り)", + "value": [",".join(SAMPLE_POST_IDS)], + }, + }, +} + +v1_data_notes_current_status: FastAPIEndpointParamDocs = { + "description": """ +取得するコミュニティノートのステータス。 + +| X 上の表示 | current_statusに指定する値 | +| :------------------------------------------------: | :-------------------------: | +| さらに評価が必要 | NEEDS_MORE_RATINGS | +| 現在のところ「役に立った」と評価されています | CURRENTLY_RATED_HELPFUL | +| 現在のところ「役に立たなかった」と評価されています | CURRENTLY_RATED_NOT_HELPFUL | +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + "needs_more_ratings": { + "summary": "さらに評価が必要なコミュニティノートを取得する", + "value": ["NEEDS_MORE_RATINGS"], + }, + "currently_rated_helpful_or_currently_rated_not_helpful": { + "summary": "評価済みのコミュニティノートを取得する", + "value": ["CURRENTLY_RATED_HELPFUL", "CURRENTLY_RATED_NOT_HELPFUL"], + }, + }, +} + +v1_data_notes_language: FastAPIEndpointParamDocs = { + "description": """ +取得するコミュニティノートの言語。 + +ISO 639-1 に準拠した 2 文字の言語コードを指定することで、その言語のコミュニティノートのみを取得できる。 +""", + "openapi_examples": { + "default": { + "summary": "指定しない (デフォルト)", + "value": None, + }, + LanguageIdentifier.EN: { + "summary": "英語のコミュニティノートを取得する", + "value": LanguageIdentifier.EN, + }, + LanguageIdentifier.JA: { + "summary": "日本語のコミュニティノートを取得する", + "value": LanguageIdentifier.JA, + }, + }, +} + + +# GET /api/v1/data/notes のクエリパラメータの OpenAPI ドキュメント +V1DataNotesDocs = FastAPIEndpointDocs( + "コミュニティノートのデータを取得するエンドポイント", + { + "note_ids": v1_data_notes_note_ids, + "created_at_from": v1_data_notes_created_at_from, + "created_at_to": v1_data_notes_created_at_to, + "offset": v1_data_notes_offset, + "limit": v1_data_notes_limit, + "topic_ids": v1_date_notes_topic_ids, + "post_ids": v1_data_notes_post_ids, + "current_status": v1_data_notes_current_status, + "language": v1_data_notes_language, + }, +) + +# 第2引数を空の辞書にすると mypy に怒られる +# が、第2引数が空の辞書でも怒られない実装にすると param 辞書の補完が効かなくなるので、エラーを無視する +V1DataTopicsDocs = FastAPIEndpointDocs( + "自動分類されたコミュニティノートのトピック一覧を取得するエンドポイント", + {}, # type: ignore[var-annotated] +) + + +v1_data_user_enrollments_participant_id: FastAPIEndpointParamDocs = { + "description": "取得するコミュニティノート参加ユーザーの ID。", + "openapi_examples": { + "single": { + "summary": "ID: B8B599F50C14003B9520DC8832612831B2D69BFC3B44C8336A800DF725396FBF のユーザーのデータを取得する", + "value": "B8B599F50C14003B9520DC8832612831B2D69BFC3B44C8336A800DF725396FBF", + }, + }, +} + +V1DataUserEnrollmentsDocs = FastAPIEndpointDocs( + "コミュニティノート参加ユーザーのデータを取得するエンドポイント", + { + "participant_id": v1_data_user_enrollments_participant_id, + }, +) diff --git a/api/birdxplorer_api/routers/data.py b/api/birdxplorer_api/routers/data.py index 57812a5..8dbde93 100644 --- a/api/birdxplorer_api/routers/data.py +++ b/api/birdxplorer_api/routers/data.py @@ -1,10 +1,18 @@ from datetime import timezone -from typing import List, Union +from typing import List, TypeAlias, Union from dateutil.parser import parse as dateutil_parse -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, HTTPException, Path, Query, Request +from pydantic import Field as PydanticField from pydantic import HttpUrl +from typing_extensions import Annotated +from birdxplorer_api.openapi_doc import ( + V1DataNotesDocs, + V1DataPostsDocs, + V1DataTopicsDocs, + V1DataUserEnrollmentsDocs, +) from birdxplorer_common.models import ( BaseModel, LanguageIdentifier, @@ -21,19 +29,129 @@ ) from birdxplorer_common.storage import Storage +PostsPaginationMetaWithExamples: TypeAlias = Annotated[ + PaginationMeta, + PydanticField( + description="ページネーション用情報。 リクエスト時に指定した offset / limit の値に応じて、次のページや前のページのリクエスト用 URL が設定される。", + json_schema_extra={ + "examples": [ + {"next": "http://birdxplorer.onrender.com/api/v1/data/posts?offset=100&limit=100", "prev": "null"} + ] + }, + ), +] + +NotesPaginationMetaWithExamples: TypeAlias = Annotated[ + PaginationMeta, + PydanticField( + description="ページネーション用情報。 リクエスト時に指定した offset / limit の値に応じて、次のページや前のページのリクエスト用 URL が設定される。", + json_schema_extra={ + "examples": [ + {"next": "http://birdxplorer.onrender.com/api/v1/data/notes?offset=100&limit=100", "prev": "null"} + ] + }, + ), +] + +TopicListWithExamples: TypeAlias = Annotated[ + List[Topic], + PydanticField( + description="推定されたトピックのリスト", + json_schema_extra={ + "examples": [ + [ + {"label": {"en": "Human rights", "ja": "人権"}, "referenceCount": 5566, "topicId": 28}, + {"label": {"en": "Media", "ja": "メディア"}, "referenceCount": 3474, "topicId": 25}, + ] + ] + }, + ), +] + +NoteListWithExamples: TypeAlias = Annotated[ + List[Note], + PydanticField( + description="コミュニティノートのリスト", + json_schema_extra={ + "examples": [ + { + "noteId": "1845672983001710655", + "postId": "1842116937066955027", + "language": "ja", + "topics": [ + { + "topicId": 26, + "label": {"ja": "セキュリティ上の脅威", "en": "security threat"}, + "referenceCount": 0, + }, + {"topicId": 47, "label": {"ja": "検閲", "en": "Censorship"}, "referenceCount": 0}, + {"topicId": 51, "label": {"ja": "テクノロジー", "en": "technology"}, "referenceCount": 0}, + ], + "summary": "Content Security Policyは情報の持ち出しを防止する仕組みではありません。コンテンツインジェクションの脆弱性のリスクを軽減する仕組みです。適切なContent Security Policyがレスポンスヘッダーに設定されている場合でも、外部への通信をブロックできない点に注意が必要です。 Content Security Policy Level 3 https://w3c.github.io/webappsec-csp/", # noqa: E501 + "currentStatus": "NEEDS_MORE_RATINGS", + "createdAt": 1728877704750, + }, + ] + }, + ), +] + +PostListWithExamples: TypeAlias = Annotated[ + List[Post], + PydanticField( + description="X の Post のリスト", + json_schema_extra={ + "examples": [ + { + "postId": "1846718284369912064", + "xUserId": "90954365", + "xUser": { + "userId": "90954365", + "name": "earthquakejapan", + "profileImage": "https://pbs.twimg.com/profile_images/1638600342/japan_rel96_normal.jpg", + "followersCount": 162934, + "followingCount": 6, + }, + "text": "今後48時間以内に日本ではマグニチュード6.0の地震が発生する可能性があります。地図をご覧ください。(10月17日~10月18日) - https://t.co/nuyiVdM4FW https://t.co/Xd6U9XkpbL", # noqa: E501 + "mediaDetails": [ + { + "mediaKey": "3_1846718279236177920-1846718284369912064", + "type": "photo", + "url": "https://pbs.twimg.com/media/GaDcfZoX0AAko2-.jpg", + "width": 900, + "height": 738, + } + ], + "createdAt": 1729094524000, + "likeCount": 451, + "repostCount": 104, + "impressionCount": 82378, + "links": [ + { + "linkId": "9c139b99-8111-e4f0-ad41-fc9e40d08722", + "url": "https://www.quakeprediction.com/Earthquake%20Forecast%20Japan.html", + } + ], + "link": "https://x.com/earthquakejapan/status/1846718284369912064", + }, + ] + }, + ), +] + class TopicListResponse(BaseModel): - data: List[Topic] + data: TopicListWithExamples class NoteListResponse(BaseModel): - data: List[Note] - meta: PaginationMeta + data: NoteListWithExamples + meta: NotesPaginationMetaWithExamples class PostListResponse(BaseModel): - data: List[Post] - meta: PaginationMeta + data: PostListWithExamples + meta: PostsPaginationMetaWithExamples def str_to_twitter_timestamp(s: str) -> TwitterTimestamp: @@ -57,31 +175,37 @@ def ensure_twitter_timestamp(t: Union[str, TwitterTimestamp]) -> TwitterTimestam def gen_router(storage: Storage) -> APIRouter: router = APIRouter() - @router.get("/user-enrollments/{participant_id}", response_model=UserEnrollment) + @router.get( + "/user-enrollments/{participant_id}", + description=V1DataUserEnrollmentsDocs.description, + response_model=UserEnrollment, + ) def get_user_enrollment_by_participant_id( - participant_id: ParticipantId, + participant_id: ParticipantId = Path(**V1DataUserEnrollmentsDocs.params["participant_id"]), ) -> UserEnrollment: res = storage.get_user_enrollment_by_participant_id(participant_id=participant_id) if res is None: raise ValueError(f"participant_id={participant_id} not found") return res - @router.get("/topics", response_model=TopicListResponse) + @router.get("/topics", description=V1DataTopicsDocs.description, response_model=TopicListResponse) def get_topics() -> TopicListResponse: return TopicListResponse(data=list(storage.get_topics())) - @router.get("/notes", response_model=NoteListResponse) + @router.get("/notes", description=V1DataNotesDocs.description, response_model=NoteListResponse) def get_notes( request: Request, - note_ids: Union[List[NoteId], None] = Query(default=None), - created_at_from: Union[None, TwitterTimestamp] = Query(default=None), - created_at_to: Union[None, TwitterTimestamp] = Query(default=None), - offset: int = Query(default=0, ge=0), - limit: int = Query(default=100, gt=0, le=1000), - topic_ids: Union[List[TopicId], None] = Query(default=None), - post_ids: Union[List[PostId], None] = Query(default=None), - current_status: Union[None, List[str]] = Query(default=None), - language: Union[LanguageIdentifier, None] = Query(default=None), + note_ids: Union[List[NoteId], None] = Query(default=None, **V1DataNotesDocs.params["note_ids"]), + created_at_from: Union[None, TwitterTimestamp] = Query( + default=None, **V1DataNotesDocs.params["created_at_from"] + ), + created_at_to: Union[None, TwitterTimestamp] = Query(default=None, **V1DataNotesDocs.params["created_at_to"]), + offset: int = Query(default=0, ge=0, **V1DataNotesDocs.params["offset"]), + limit: int = Query(default=100, gt=0, le=1000, **V1DataNotesDocs.params["limit"]), + topic_ids: Union[List[TopicId], None] = Query(default=None, **V1DataNotesDocs.params["topic_ids"]), + post_ids: Union[List[PostId], None] = Query(default=None, **V1DataNotesDocs.params["post_ids"]), + current_status: Union[None, List[str]] = Query(default=None, **V1DataNotesDocs.params["current_status"]), + language: Union[LanguageIdentifier, None] = Query(default=None, **V1DataNotesDocs.params["language"]), ) -> NoteListResponse: if created_at_from is not None and isinstance(created_at_from, str): created_at_from = ensure_twitter_timestamp(created_at_from) @@ -123,18 +247,22 @@ def get_notes( return NoteListResponse(data=notes, meta=PaginationMeta(next=next_url, prev=prev_url)) - @router.get("/posts", response_model=PostListResponse) + @router.get("/posts", description=V1DataPostsDocs.description, response_model=PostListResponse) def get_posts( request: Request, post_ids: Union[List[PostId], None] = Query(default=None), note_ids: Union[List[NoteId], None] = Query(default=None), - created_at_from: Union[None, TwitterTimestamp, str] = Query(default=None), - created_at_to: Union[None, TwitterTimestamp, str] = Query(default=None), - offset: int = Query(default=0, ge=0), - limit: int = Query(default=100, gt=0, le=1000), - search_text: Union[None, str] = Query(default=None), - search_url: Union[None, HttpUrl] = Query(default=None), - media: bool = Query(default=True), + created_at_from: Union[None, TwitterTimestamp, str] = Query( + default=None, **V1DataPostsDocs.params["created_at_from"] + ), + created_at_to: Union[None, TwitterTimestamp, str] = Query( + default=None, **V1DataPostsDocs.params["created_at_to"] + ), + offset: int = Query(default=0, ge=0, **V1DataPostsDocs.params["offset"]), + limit: int = Query(default=100, gt=0, le=1000, **V1DataPostsDocs.params["limit"]), + search_text: Union[None, str] = Query(default=None, **V1DataPostsDocs.params["search_text"]), + search_url: Union[None, HttpUrl] = Query(default=None, **V1DataPostsDocs.params["search_url"]), + media: bool = Query(default=True, **V1DataPostsDocs.params["media"]), ) -> PostListResponse: if created_at_from is not None and isinstance(created_at_from, str): created_at_from = ensure_twitter_timestamp(created_at_from) @@ -153,6 +281,7 @@ def get_posts( with_media=media, ) ) + total_count = storage.get_number_of_posts( post_ids=post_ids, note_ids=note_ids, diff --git a/common/birdxplorer_common/models.py b/common/birdxplorer_common/models.py index 2a81b67..060949a 100644 --- a/common/birdxplorer_common/models.py +++ b/common/birdxplorer_common/models.py @@ -30,7 +30,7 @@ from pydantic.alias_generators import to_camel from pydantic.json_schema import JsonSchemaValue from pydantic.main import IncEx -from pydantic_core import core_schema +from pydantic_core import Url, core_schema StrT = TypeVar("StrT", bound="BaseString") IntT = TypeVar("IntT", bound="BaseInt") @@ -666,22 +666,39 @@ class TopicLabelString(NonEmptyTrimmedString): ... class Topic(BaseModel): model_config = ConfigDict(from_attributes=True) - topic_id: TopicId - label: TopicLabel - reference_count: NonNegativeInt + topic_id: Annotated[TopicId, PydanticField(description="トピックの ID")] + label: Annotated[TopicLabel, PydanticField(description="トピックの言語ごとのラベル")] + reference_count: Annotated[ + NonNegativeInt, PydanticField(description="このトピックに分類されたコミュニティノートの数") + ] class SummaryString(NonEmptyTrimmedString): ... class Note(BaseModel): - note_id: NoteId - post_id: PostId - language: LanguageIdentifier - topics: List[Topic] - summary: SummaryString - current_status: str | None - created_at: TwitterTimestamp + note_id: Annotated[NoteId, PydanticField(description="コミュニティノートの ID")] + post_id: Annotated[PostId, PydanticField(description="コミュニティノートに対応する X の Post の ID")] + language: Annotated[LanguageIdentifier, PydanticField(description="コミュニティノートの言語")] + topics: Annotated[List[Topic], PydanticField(description="推定されたコミュニティノートのトピック")] + summary: Annotated[SummaryString, PydanticField(description="コミュニティノートの本文")] + current_status: Annotated[ + Annotated[ + str, + PydanticField( + json_schema_extra={ + "enum": ["NEEDS_MORE_RATINGS", "CURRENTLY_RATED_HELPFUL", "CURRENTLY_RATED_NOT_HELPFUL"] + }, + ), + ] + | None, + PydanticField( + description="コミュニティノートの現在の評価状態", + ), + ] + created_at: Annotated[ + TwitterTimestamp, PydanticField(description="コミュニティノートの作成日時 (ミリ秒単位の UNIX EPOCH TIMESTAMP)") + ] class UserId(UpToNineteenDigitsDecimalString): ... @@ -691,11 +708,11 @@ class UserName(NonEmptyTrimmedString): ... class XUser(BaseModel): - user_id: UserId - name: UserName - profile_image: HttpUrl - followers_count: NonNegativeInt - following_count: NonNegativeInt + user_id: Annotated[UserId, PydanticField(description="X ユーザーの ID")] + name: Annotated[UserName, PydanticField(description="X ユーザーのスクリーンネーム")] + profile_image: Annotated[HttpUrl, PydanticField(description="X ユーザーのプロフィール画像の URL")] + followers_count: Annotated[NonNegativeInt, PydanticField(description="X ユーザーのフォロワー数")] + following_count: Annotated[NonNegativeInt, PydanticField(description="X ユーザーのフォロー数")] # ref: https://developer.x.com/en/docs/x-api/data-dictionary/object-model/media @@ -703,12 +720,12 @@ class XUser(BaseModel): class Media(BaseModel): - media_key: str + media_key: Annotated[str, PydanticField(description="X 上でメディアを一意に識別できるキー")] - type: MediaType - url: HttpUrl - width: NonNegativeInt - height: NonNegativeInt + type: Annotated[MediaType, PydanticField(description="メディアの種類")] + url: Annotated[HttpUrl, PydanticField(description="メディアの URL")] + width: Annotated[NonNegativeInt, PydanticField(description="メディアの幅")] + height: Annotated[NonNegativeInt, PydanticField(description="メディアの高さ")] MediaDetails: TypeAlias = List[Media] @@ -777,6 +794,10 @@ def serialize(self) -> str: class Link(BaseModel): """ + X に投稿された Post 内のリンク情報を正規化して保持するためのモデル。 + + t.co に短縮される前の URL ごとに一意な ID を持つ。 + >>> Link.model_validate_json('{"linkId": "d5d15194-6574-0c01-8f6f-15abd72b2cf6", "url": "https://example.com"}') Link(link_id=LinkId('d5d15194-6574-0c01-8f6f-15abd72b2cf6'), url=Url('https://example.com/')) >>> Link(url="https://example.com/") @@ -785,8 +806,8 @@ class Link(BaseModel): Link(link_id=LinkId('d5d15194-6574-0c01-8f6f-15abd72b2cf6'), url=Url('https://example.com/')) """ # noqa: E501 - link_id: LinkId - url: HttpUrl + link_id: Annotated[LinkId, PydanticField(description="リンクを識別できる UUID")] + url: Annotated[HttpUrl, PydanticField(description="リンクが指す URL")] @model_validator(mode="before") def validate_link_id(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -796,41 +817,42 @@ def validate_link_id(cls, values: Dict[str, Any]) -> Dict[str, Any]: class Post(BaseModel): - post_id: PostId - x_user_id: UserId - x_user: XUser - text: str - media_details: Annotated[MediaDetails, PydanticField(default_factory=lambda: [])] - created_at: TwitterTimestamp - like_count: NonNegativeInt - repost_count: NonNegativeInt - impression_count: NonNegativeInt - links: List[Link] = [] - + post_id: Annotated[PostId, PydanticField(description="X の Post の ID")] + x_user_id: Annotated[UserId, PydanticField(description="Post を投稿したユーザーの ID。`xUser.userId` と同じ")] + x_user: Annotated[XUser, PydanticField(description="Post を投稿したユーザーの情報")] + text: Annotated[str, PydanticField(description="Post の本文")] + media_details: Annotated[ + MediaDetails, PydanticField(default_factory=lambda: [], description="Post に含まれるメディア情報のリスト") + ] + created_at: Annotated[ + TwitterTimestamp, PydanticField(description="Post の作成日時 (ミリ秒単位の UNIX EPOCH TIMESTAMP)") + ] + like_count: Annotated[NonNegativeInt, PydanticField(description="Post のいいね数")] + repost_count: Annotated[NonNegativeInt, PydanticField(description="Post のリポスト数")] + impression_count: Annotated[NonNegativeInt, PydanticField(description="Post の表示回数")] + links: Annotated[ + List[Link], PydanticField(default_factory=lambda: [], description="Post に含まれるリンク情報のリスト") + ] + + @computed_field(description="Post を X 上で表示する URL") # type: ignore[prop-decorator] @property - @computed_field def link(self) -> HttpUrl: """ PostのX上でのURLを返す。 - - Examples - -------- - >>> post = Post(post_id="1234567890123456789", - x_user_id="1234567890123456789", - x_user=XUser(user_id="1234567890123456789", - name="test", - profile_image="https://x.com/test"), - text="test", - created_at=1288834974657, - like_count=1, - repost_count=1, - impression_count=1) - >>> post.link - HttpUrl('https://x.com/test/status/1234567890123456789') """ - return HttpUrl(f"https://x.com/{self.x_user.name}/status/{self.post_id}") + return Url(f"https://x.com/{self.x_user.name}/status/{self.post_id}") class PaginationMeta(BaseModel): - next: Optional[HttpUrl] = None - prev: Optional[HttpUrl] = None + next: Annotated[ + Optional[HttpUrl], + PydanticField( + description="次のページのリクエスト用 URL", + ), + ] = None + prev: Annotated[ + Optional[HttpUrl], + PydanticField( + description="前のページのリクエスト用 URL", + ), + ] = None