From 6c7baee949d29544bf3596f1bf341d69db8c3527 Mon Sep 17 00:00:00 2001 From: Polle Date: Sun, 17 Aug 2025 01:50:37 +0000 Subject: [PATCH] Tildes API | Read-Only Endpoints See merge request tildes/tildes!160 --- tildes/openapi_beta.yaml | 439 ++++++++++++++++++++++ tildes/requirements-dev.txt | 17 + tildes/requirements.in | 1 + tildes/requirements.txt | 17 + tildes/tildes/__init__.py | 1 + tildes/tildes/lib/id.py | 18 + tildes/tildes/models/topic/topic_query.py | 1 + tildes/tildes/routes.py | 12 + tildes/tildes/views/api/beta/__init__.py | 1 + tildes/tildes/views/api/beta/api_utils.py | 91 +++++ tildes/tildes/views/api/beta/comment.py | 79 ++++ tildes/tildes/views/api/beta/topic.py | 171 +++++++++ tildes/tildes/views/api/beta/user.py | 217 +++++++++++ tildes/tildes/views/api/v0/__init__.py | 1 - tildes/tildes/views/api/v0/group.py | 18 - tildes/tildes/views/api/v0/topic.py | 20 - tildes/tildes/views/api/v0/user.py | 18 - 17 files changed, 1065 insertions(+), 57 deletions(-) create mode 100644 tildes/openapi_beta.yaml create mode 100644 tildes/tildes/views/api/beta/__init__.py create mode 100644 tildes/tildes/views/api/beta/api_utils.py create mode 100644 tildes/tildes/views/api/beta/comment.py create mode 100644 tildes/tildes/views/api/beta/topic.py create mode 100644 tildes/tildes/views/api/beta/user.py delete mode 100644 tildes/tildes/views/api/v0/__init__.py delete mode 100644 tildes/tildes/views/api/v0/group.py delete mode 100644 tildes/tildes/views/api/v0/topic.py delete mode 100644 tildes/tildes/views/api/v0/user.py diff --git a/tildes/openapi_beta.yaml b/tildes/openapi_beta.yaml new file mode 100644 index 0000000..8f4de6e --- /dev/null +++ b/tildes/openapi_beta.yaml @@ -0,0 +1,439 @@ +openapi: 3.0.0 +info: + title: Tildes Beta API Schema + version: Beta + description: | + This is the OpenAPI schema for the Tildes Beta API. + The beta API is subject to change and may not be fully stable. + Future updates WILL include breaking changes. + Use at your own risk. +servers: + - url: /api/beta +paths: + /topics: + get: + summary: Get a list of topics + parameters: + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationBefore' + - $ref: '#/components/parameters/paginationAfter' + - in: query + name: period + schema: + type: string + default: "all" + required: false + description: The time period for which to retrieve topics. For example "4h" or "2d". + - in: query + name: tag + schema: + type: string + required: false + description: The tag to filter topics by. If not specified, topics are not filtered on their tags. + - in: query + name: order + schema: + type: string + default: "activity" + enum: ["activity", "votes", "comments", "new", "all_activity"] + required: false + description: The sort order for the topics. Defaults to "activity". + responses: + "200": + description: A list of topics + content: + application/json: + schema: + type: object + required: + - topics + - pagination + properties: + topics: + type: array + items: + $ref: '#/components/schemas/Topic' + pagination: + $ref: '#/components/schemas/Pagination' + "400": + $ref: "#/components/responses/ValidationError" + + /topic/{topic_id36}: + get: + summary: Get a single topic and its comments + parameters: + - in: path + name: topic_id36 + schema: + type: string + required: true + description: The ID36 of the topic to retrieve. + - in: query + name: order + schema: + type: string + default: "relevance" + enum: ["votes", "newest", "posted", "relevance"] + required: false + description: The sort order for the comment tree. Defaults to "relevance". + responses: + "200": + description: A single topic and its comments + content: + application/json: + schema: + type: object + required: + - topic + - comments + properties: + topic: + $ref: '#/components/schemas/Topic' + comments: + type: array + items: + $ref: '#/components/schemas/Comment' + "400": + $ref: "#/components/responses/ValidationError" + + /user/{username}: + get: + summary: Get a user along with their history + parameters: + - in: path + name: username + schema: + type: string + required: true + description: The username of the user to retrieve. + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationBefore' + - $ref: '#/components/parameters/paginationAfter' + responses: + "200": + description: Basic user information and their post/comment history + content: + application/json: + schema: + type: object + required: + - user + - history + - pagination + properties: + user: + $ref: '#/components/schemas/User' + history: + type: array + items: + anyOf: + - $ref: '#/components/schemas/Topic' + - $ref: '#/components/schemas/Comment' + pagination: + $ref: '#/components/schemas/Pagination' + "400": + $ref: "#/components/responses/ValidationError" + "403": + $ref: "#/components/responses/AuthorizationError" + + /user/{username}/comments: + get: + summary: Get comments made by a user + parameters: + - in: path + name: username + schema: + type: string + required: true + description: The username of the user for whom to retrieve comments. + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationBefore' + - $ref: '#/components/parameters/paginationAfter' + responses: + "200": + description: A list of comments made by the user + content: + application/json: + schema: + type: object + required: + - comments + - pagination + properties: + comments: + type: array + items: + $ref: '#/components/schemas/Comment' + pagination: + $ref: '#/components/schemas/Pagination' + "400": + $ref: "#/components/responses/ValidationError" + "403": + $ref: "#/components/responses/AuthorizationError" + + /user/{username}/topics: + get: + summary: Get topics made by a user + parameters: + - in: path + name: username + schema: + type: string + required: true + description: The username of the user for whom to retrieve topics. + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationBefore' + - $ref: '#/components/parameters/paginationAfter' + responses: + "200": + description: A list of topics made by the user + content: + application/json: + schema: + type: object + required: + - topics + - pagination + properties: + topics: + type: array + items: + $ref: '#/components/schemas/Topic' + pagination: + $ref: '#/components/schemas/Pagination' + "400": + $ref: "#/components/responses/ValidationError" + "403": + $ref: "#/components/responses/AuthorizationError" + +components: + parameters: + paginationBefore: + in: query + name: before + schema: + type: string + required: false + description: The ID36 of the first item from the current page, to get items before it. You can only specify either `before` or `after`, not both. In mixed feeds like user profiles, this parameter needs a "t-" or "c-" prefix to indicate topics and comments respectively. + + paginationAfter: + in: query + name: after + schema: + type: string + required: false + description: The ID36 of the last item from the previous page, to get items after it. You can only specify either `before` or `after`, not both. In mixed feeds like user profiles, this parameter needs a "t-" or "c-" prefix to indicate topics and comments respectively. + + paginationLimit: + in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + required: false + description: The maximum number of items to return. The `limit` is itself limited to prevent abuse. + + responses: + ValidationError: + description: OpenAPI request/response validation failed + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Error" + AuthorizationError: + description: You are not authorized to perform this action + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Error" + + schemas: + Topic: + type: object + required: + - id + - title + - text_html + - url + - comments_url + - group + - content_metadata + - created_at + - edited_at + - posted_by_user + - vote_count + - comment_count + - new_comment_count + - voted + - bookmarked + - ignored + - official + - tags + - last_visit_time + properties: + id: + type: string + title: + type: string + text_html: + type: string + nullable: true + url: + type: string + nullable: true + comments_url: + type: string + source_site_name: + type: string + nullable: true + source_site_icon: + type: string + nullable: true + group: + type: string + content_metadata: + type: array + items: + type: string + created_at: + type: string + edited_at: + type: string + nullable: true + posted_by_user: + type: string + vote_count: + type: integer + comment_count: + type: integer + new_comment_count: + type: integer + nullable: true + voted: + type: boolean + nullable: true + bookmarked: + type: boolean + nullable: true + ignored: + type: boolean + nullable: true + official: + type: boolean + tags: + type: array + items: + type: string + last_visit_time: + type: string + nullable: true + + Comment: + type: object + required: + - id + - topic_id + - author + - rendered_html + - created_at + - edited_at + - votes + - is_removed + - is_deleted + - exemplary + - voted + - bookmarked + properties: + id: + type: string + topic_id: + type: string + author: + type: string + nullable: true + rendered_html: + type: string + nullable: true + created_at: + type: string + edited_at: + type: string + nullable: true + votes: + type: integer + is_removed: + type: boolean + is_deleted: + type: boolean + exemplary: + type: boolean + nullable: true + collapsed: + type: boolean + nullable: true + collapsed_individual: + type: boolean + nullable: true + is_op: + type: boolean + nullable: true + is_me: + type: boolean + nullable: true + is_new: + type: boolean + nullable: true + voted: + type: boolean + nullable: true + bookmarked: + type: boolean + nullable: true + depth: + type: integer + children: + type: array + items: + $ref: '#/components/schemas/Comment' + + User: + type: object + required: + - username + properties: + username: + type: string + + Pagination: + type: object + required: + - num_items + - next_link + - prev_link + properties: + num_items: + type: integer + description: The number of items returned in this response. + next_link: + type: string + nullable: true + prev_link: + type: string + nullable: true + + Error: + type: object + required: + - message + properties: + field: + type: string + message: + type: string + exception: + type: string diff --git a/tildes/requirements-dev.txt b/tildes/requirements-dev.txt index 8cd35f1..468cac2 100644 --- a/tildes/requirements-dev.txt +++ b/tildes/requirements-dev.txt @@ -4,6 +4,7 @@ argon2-cffi==25.1.0 argon2-cffi-bindings==25.1.0 astroid==3.3.11 asttokens==3.0.0 +attrs==25.3.0 beautifulsoup4==4.13.4 black==25.1.0 bleach==6.2.0 @@ -31,20 +32,31 @@ iniconfig==2.1.0 invoke==2.2.0 ipython==9.4.0 ipython-pygments-lexers==1.1.1 +isodate==0.7.2 isort==6.0.1 jedi==0.19.2 jinja2==3.1.6 +jsonschema==4.25.0 +jsonschema-path==0.3.4 +jsonschema-specifications==2025.4.1 +lazy-object-proxy==1.11.0 lupa==2.5 mako==1.3.10 markupsafe==3.0.2 marshmallow==3.25.1 matplotlib-inline==0.1.7 mccabe==0.7.0 +more-itertools==10.7.0 mypy==1.17.1 mypy-extensions==1.1.0 +openapi-core==0.19.5 +openapi-schema-validator==0.6.3 +openapi-spec-validator==0.7.2 packaging==25.0 +parse==1.20.2 parso==0.8.4 pastedeploy==3.1.0 +pathable==0.4.4 pathspec==0.12.1 pep8-naming==0.10.0 pexpect==4.9.0 @@ -78,6 +90,7 @@ pyramid-debugtoolbar==4.12.1 pyramid-ipython==0.2 pyramid-jinja2==2.10.1 pyramid-mako==1.1.0 +pyramid-openapi3==0.21.0 pyramid-session-redis==1.5.0 pyramid-tm==2.6 pyramid-webassets==0.10 @@ -87,8 +100,11 @@ python-dateutil==2.9.0.post0 pyyaml==6.0.2 qrcode==8.2 redis==3.5.3 +referencing==0.36.2 requests==2.32.4 requirements-detector==1.4.0 +rfc3339-validator==0.1.4 +rpds-py==0.27.0 semver==3.0.4 sentry-sdk==1.3.0 setoptconf-tmp==0.3.1 @@ -124,6 +140,7 @@ webassets==2.0 webencodings==0.5.1 webob==1.8.9 webtest==3.0.6 +werkzeug==3.1.1 wheel==0.45.1 wrapt==1.17.2 zope-deprecation==5.1 diff --git a/tildes/requirements.in b/tildes/requirements.in index ebe0016..4401b49 100644 --- a/tildes/requirements.in +++ b/tildes/requirements.in @@ -22,6 +22,7 @@ pyotp pyramid<2.0 pyramid-ipython pyramid-jinja2 +pyramid-openapi3>=0.17.0 pyramid-session-redis==1.5.0 # 1.5.1 has a change that will invalidate current sessions pyramid-tm pyramid-webassets diff --git a/tildes/requirements.txt b/tildes/requirements.txt index 8402774..f0b1db8 100644 --- a/tildes/requirements.txt +++ b/tildes/requirements.txt @@ -3,6 +3,7 @@ alembic==1.14.1 argon2-cffi==25.1.0 argon2-cffi-bindings==25.1.0 asttokens==3.0.0 +attrs==25.3.0 beautifulsoup4==4.13.4 bleach==6.2.0 build==1.3.0 @@ -20,16 +21,27 @@ idna==3.10 invoke==2.2.0 ipython==9.4.0 ipython-pygments-lexers==1.1.1 +isodate==0.7.2 jedi==0.19.2 jinja2==3.1.6 +jsonschema==4.25.0 +jsonschema-path==0.3.4 +jsonschema-specifications==2025.4.1 +lazy-object-proxy==1.11.0 lupa==2.5 mako==1.3.10 markupsafe==3.0.2 marshmallow==3.25.1 matplotlib-inline==0.1.7 +more-itertools==10.7.0 +openapi-core==0.19.5 +openapi-schema-validator==0.6.3 +openapi-spec-validator==0.7.2 packaging==25.0 +parse==1.20.2 parso==0.8.4 pastedeploy==3.1.0 +pathable==0.4.4 pexpect==4.9.0 pillow==11.3.0 pip-tools==7.5.0 @@ -49,6 +61,7 @@ pyproject-hooks==1.2.0 pyramid==1.10.8 pyramid-ipython==0.2 pyramid-jinja2==2.10.1 +pyramid-openapi3==0.21.0 pyramid-session-redis==1.5.0 pyramid-tm==2.6 pyramid-webassets==0.10 @@ -56,7 +69,10 @@ python-dateutil==2.9.0.post0 pyyaml==6.0.2 qrcode==8.2 redis==3.5.3 +referencing==0.36.2 requests==2.32.4 +rfc3339-validator==0.1.4 +rpds-py==0.27.0 sentry-sdk==1.3.0 six==1.17.0 soupsieve==2.7 @@ -77,6 +93,7 @@ webargs==8.0.0 webassets==2.0 webencodings==0.5.1 webob==1.8.9 +werkzeug==3.1.1 wheel==0.45.1 wrapt==1.17.2 zope-deprecation==5.1 diff --git a/tildes/tildes/__init__.py b/tildes/tildes/__init__.py index 58e626f..7f9a6e7 100644 --- a/tildes/tildes/__init__.py +++ b/tildes/tildes/__init__.py @@ -18,6 +18,7 @@ def main(global_config: dict[str, str], **settings: str) -> PrefixMiddleware: config.include("cornice") config.include("pyramid_session_redis") config.include("pyramid_webassets") + config.include("pyramid_openapi3") # include database first so the session and querying are available config.include("tildes.database") diff --git a/tildes/tildes/lib/id.py b/tildes/tildes/lib/id.py index 6b331e6..88584d8 100644 --- a/tildes/tildes/lib/id.py +++ b/tildes/tildes/lib/id.py @@ -5,6 +5,7 @@ import re import string +from typing import Literal, Tuple ID36_REGEX = re.compile("^[a-z0-9]+$", re.IGNORECASE) @@ -41,3 +42,20 @@ def id36_to_id(id36_val: str) -> int: # Python's stdlib can handle this, much simpler in this direction return int(id36_val, 36) + + +def split_anchored_id(anchored_id: str) -> Tuple[Literal["comment", "topic"], str]: + """Extract the anchor part from an anchored ID.""" + if not anchored_id or not isinstance(anchored_id, str): + raise ValueError("Invalid anchored ID provided") + + type_char, _, id36 = anchored_id.partition("-") + if not id36: + raise ValueError("Invalid anchored ID provided") + + if type_char == "c": + return ("comment", id36) + elif type_char == "t": + return ("topic", id36) + else: + raise ValueError(f"Invalid anchored ID type: {type_char}") diff --git a/tildes/tildes/models/topic/topic_query.py b/tildes/tildes/models/topic/topic_query.py index 8eb63a8..88694f9 100644 --- a/tildes/tildes/models/topic/topic_query.py +++ b/tildes/tildes/models/topic/topic_query.py @@ -142,6 +142,7 @@ class TopicQuery(PaginatedQuery): topic.bookmark_created_time = None topic.last_visit_time = None topic.comments_since_last_visit = None + topic.user_bookmarked = None topic.user_ignored = False else: topic = result.Topic diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index bd8b8f4..ea77bba 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -128,6 +128,18 @@ def includeme(config: Configurator) -> None: config.add_route("shortener_group", "/~{path}", factory=group_by_path) config.add_route("shortener_topic", "/{topic_id36}", factory=topic_by_id36) + # Routes for the JSON API + # We also provide a path for the full spec and the built-in swagger UI explorer + config.pyramid_openapi3_spec("openapi_beta.yaml", route="/api/beta/openapi.yaml") + config.pyramid_openapi3_add_explorer(route="/api/beta/ui") + + with config.route_prefix_context("/api/beta"): + config.add_route("apibeta.topics", "/topics") + config.add_route("apibeta.topic", "/topic/{topic_id36}") + config.add_route("apibeta.user", "/user/{username}") + config.add_route("apibeta.user_comments", "/user/{username}/comments") + config.add_route("apibeta.user_topics", "/user/{username}/topics") + def add_intercooler_routes(config: Configurator) -> None: """Set up all routes for the (internal-use) Intercooler API endpoints.""" diff --git a/tildes/tildes/views/api/beta/__init__.py b/tildes/tildes/views/api/beta/__init__.py new file mode 100644 index 0000000..a518b4a --- /dev/null +++ b/tildes/tildes/views/api/beta/__init__.py @@ -0,0 +1 @@ +"""Contains views for the JSON web API.""" diff --git a/tildes/tildes/views/api/beta/api_utils.py b/tildes/tildes/views/api/beta/api_utils.py new file mode 100644 index 0000000..e997f07 --- /dev/null +++ b/tildes/tildes/views/api/beta/api_utils.py @@ -0,0 +1,91 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""JSON API utils.""" + +from typing import Tuple +from pyramid.request import Request +from pyramid.response import Response +from tildes.lib.id import split_anchored_id +from tildes.models.pagination import PaginatedQuery, PaginatedResults + + +def query_apply_pagination( # noqa + query: PaginatedQuery, + before: str | None, + after: str | None, + error_if_no_anchor: bool = False, +) -> PaginatedQuery: + """Apply pagination parameters to a query.""" + # Start by parsing the before/after parameters and extracting the anchor type + # We don't know if the ID has an anchor, so we just try to split it + # If it doesn't have an anchor, we just use the ID as is. + anchor_type = None + if before and after: + raise ValueError("Cannot specify both before and after parameters") + if before: + try: + anchor_type, before = split_anchored_id(before) + except ValueError as exc: + if error_if_no_anchor: + raise ValueError( + "Expected an anchored ID for 'before' parameter" + ) from exc + if after: + try: + anchor_type, after = split_anchored_id(after) + except ValueError as exc: + if error_if_no_anchor: + raise ValueError( + "Expected an anchored ID for 'after' parameter" + ) from exc + + if anchor_type: + query = query.anchor_type(anchor_type) + if before: + query = query.before_id36(before) + if after: + query = query.after_id36(after) + return query + + +def get_next_and_prev_link( + request: Request, page: PaginatedResults +) -> Tuple[str | None, str | None]: + """Get the next and previous links for pagination.""" + next_link = None + prev_link = None + + if page.has_next_page: + query_vars = request.GET.copy() + query_vars.pop("before", None) + query_vars.update({"after": page.next_page_after_id36}) + next_link = request.current_route_url(_query=query_vars) + + if page.has_prev_page: + query_vars = request.GET.copy() + query_vars.pop("after", None) + query_vars.update({"before": page.prev_page_before_id36}) + prev_link = request.current_route_url(_query=query_vars) + + return (next_link, prev_link) + + +def build_error_response( + message: str, + status: int = 400, + field: str = "N/A", + error_type: str = "ValidationError", +) -> Response: + """Build a standardized error response.""" + return Response( + status=status, + content_type="application/json", + json=[ + { + "message": message, + "field": field, + "exception": error_type, + } + ], + ) diff --git a/tildes/tildes/views/api/beta/comment.py b/tildes/tildes/views/api/beta/comment.py new file mode 100644 index 0000000..ee8da1b --- /dev/null +++ b/tildes/tildes/views/api/beta/comment.py @@ -0,0 +1,79 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""JSON API helper functions related to comments.""" + +from pyramid.request import Request +from tildes.models.comment import Comment +from tildes.models.comment.comment_tree import CommentInTree + + +def comment_to_dict(request: Request, comment: Comment) -> dict: + """Convert a Comment object to a dictionary for JSON serialization.""" + + # Check permissions for viewing comment details (and set safe defaults) + author = None + rendered_html = None + exemplary = None + is_op = None + is_me = None + is_new = None + if request.has_permission("view", comment): + author = comment.user.username + rendered_html = comment.rendered_html + exemplary = comment.is_label_active("exemplary") + is_me = request.user == comment.user if request.user else False + if request.has_permission("view_author", comment.topic): + is_op = comment.user == comment.topic.user + is_new = ( + (comment.created_time > comment.topic.last_visit_time) + if ( + hasattr(comment.topic, "last_visit_time") + and comment.topic.last_visit_time + and not is_me + ) + else False + ) + + return { + "id": comment.comment_id36, + "topic_id": comment.topic.topic_id36, + "author": author, + "rendered_html": rendered_html, + "created_at": comment.created_time.isoformat(), + "edited_at": ( + comment.last_edited_time.isoformat() if comment.last_edited_time else None + ), + "votes": comment.num_votes, + "is_removed": comment.is_removed, + "is_deleted": comment.is_deleted, + "exemplary": exemplary, + "collapsed": ( + (comment.collapsed_state == "full") + if hasattr(comment, "collapsed_state") + else None + ), + "collapsed_individual": ( + (comment.collapsed_state == "individual") + if hasattr(comment, "collapsed_state") + else None + ), + "is_op": is_op, + "is_me": is_me, + "is_new": is_new, + "voted": comment.user_voted, + "bookmarked": comment.user_bookmarked, + } + + +def comment_subtree_to_dict(request: Request, comments: list[CommentInTree]) -> list: + """Convert a comment subtree to a list of dictionaries for JSON serialization.""" + comments_list = [] + for comment in comments: + comment_dict = comment_to_dict(request, comment) + comment_dict["depth"] = comment.depth + comment_dict["children"] = ( + comment_subtree_to_dict(request, comment.replies) if comment.replies else [] + ) + comments_list.append(comment_dict) + return comments_list diff --git a/tildes/tildes/views/api/beta/topic.py b/tildes/tildes/views/api/beta/topic.py new file mode 100644 index 0000000..b8c87f3 --- /dev/null +++ b/tildes/tildes/views/api/beta/topic.py @@ -0,0 +1,171 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""JSON API endpoints related to topics.""" + +from marshmallow.exceptions import ValidationError +from pyramid.request import Request +from pyramid.view import view_config +from tildes.enums import CommentTreeSortOption, TopicSortOption +from tildes.models.comment import CommentTree, Comment +from tildes.models.topic import Topic +from tildes.schemas.fields import ShortTimePeriod +from tildes.lib.id import id36_to_id +from tildes.views.api.beta.api_utils import ( + build_error_response, + get_next_and_prev_link, + query_apply_pagination, +) +from tildes.views.api.beta.comment import comment_subtree_to_dict + + +def topic_to_dict(topic: Topic) -> dict: + """Convert a Topic object to a dictionary for JSON serialization.""" + return { + "id": topic.topic_id36, + "title": topic.title, + "text_html": topic.rendered_html, + "url": topic.link, + "comments_url": topic.permalink, + "group": str(topic.group.path), + "content_metadata": topic.content_metadata_for_display, + "created_at": topic.created_time.isoformat(), + "edited_at": ( + topic.last_edited_time.isoformat() if topic.last_edited_time else None + ), + "posted_by_user": topic.user.username, + "vote_count": topic.num_votes, + "comment_count": topic.num_comments, + "new_comment_count": topic.comments_since_last_visit, + "voted": topic.user_voted, + "bookmarked": topic.user_bookmarked, + "ignored": topic.user_ignored, + "official": topic.is_official, + "tags": topic.tags, + "last_visit_time": ( + topic.last_visit_time.isoformat() if topic.last_visit_time else None + ), + } + + +@view_config(route_name="apibeta.topics", openapi=True, renderer="json") +def get_topics(request: Request) -> dict: # noqa + """Get a list of topics (without comments).""" + limit = request.openapi_validated.parameters.query.get("limit", 50) + period_raw = request.openapi_validated.parameters.query.get("period") + tag = request.openapi_validated.parameters.query.get("tag", None) + order_raw = request.openapi_validated.parameters.query.get("order", None) + before = request.openapi_validated.parameters.query.get("before", None) + after = request.openapi_validated.parameters.query.get("after", None) + + # Parse parameters where necessary + try: + period = ShortTimePeriod(allow_none=True) + period = period.deserialize(period_raw) + except ValidationError as exc: + return build_error_response(str(exc), field="period") + + try: + if order_raw: + order = TopicSortOption[order_raw.upper()] + else: + order = TopicSortOption.ACTIVITY + except KeyError: + return build_error_response(f"Invalid order value: {order_raw}", field="order") + + query = request.query(Topic).join_all_relationships().apply_sort_option(order) + + # restrict the time period, if not set to "all time" + if period: + query = query.inside_time_period(period) + + # restrict to a specific tag if provided + if tag: + query = query.has_tag(tag) + + # apply before/after pagination restrictions if relevant + try: + query = query_apply_pagination(query, before, after, error_if_no_anchor=False) + except ValueError as exc: + return build_error_response(str(exc), field="pagination") + + # Execute the query + topics = query.get_page(limit) + processed_topics = [] + + # Build the JSON topic data + for topic in topics: + processed_topic = topic_to_dict(topic) + processed_topics.append(processed_topic) + + # Construct the paging next and previous link if there are more topics + (next_link, prev_link) = get_next_and_prev_link(request, topics) + + # Construct the final response JSON object + response = { + "topics": processed_topics, + "pagination": { + "num_items": len(processed_topics), + "next_link": next_link, + "prev_link": prev_link, + }, + } + return response + + +@view_config(route_name="apibeta.topic", openapi=True, renderer="json") +def get_topic(request: Request) -> dict: + """Get a single topic (with comments).""" + topic_id36 = request.openapi_validated.parameters.path.get("topic_id36") + comment_order_raw = request.openapi_validated.parameters.query.get("order") + + comment_order = CommentTreeSortOption.RELEVANCE + if comment_order_raw is not None: + try: + comment_order = CommentTreeSortOption[comment_order_raw.upper()] + except KeyError: + return build_error_response( + f"Invalid order value: {comment_order_raw}", + field="order", + ) + else: + if request.user and request.user.comment_sort_order_default: + comment_order = request.user.comment_sort_order_default + else: + comment_order = CommentTreeSortOption.RELEVANCE + + try: + topic_id = id36_to_id(topic_id36) + query = request.query(Topic).filter(Topic.topic_id == topic_id) + topic = query.one_or_none() + if not topic: + raise ValueError(f"Topic with ID {topic_id36} not found") + except ValueError as exc: + return build_error_response(str(exc), field="topic_id36") + + # deleted and removed comments need to be included since they're necessary for + # building the tree if they have replies + comments = ( + request.query(Comment) + .include_deleted() + .include_removed() + .filter(Comment.topic == topic) + .order_by(Comment.created_time) + .all() + ) + + tree = CommentTree(comments, comment_order, request.user) + tree.collapse_from_labels() + + if request.user: + # collapse old comments if the user has a previous visit to the topic + # (and doesn't have that behavior disabled) + if topic.last_visit_time and request.user.collapse_old_comments: + tree.uncollapse_new_comments(topic.last_visit_time) + tree.finalize_collapsing_maximized() + + commentsjson = comment_subtree_to_dict(request, tree.tree) + + # Construct the final response JSON object + response = {"topic": topic_to_dict(topic), "comments": commentsjson} + return response diff --git a/tildes/tildes/views/api/beta/user.py b/tildes/tildes/views/api/beta/user.py new file mode 100644 index 0000000..3b5b865 --- /dev/null +++ b/tildes/tildes/views/api/beta/user.py @@ -0,0 +1,217 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""JSON API endpoints related to users.""" + +from typing import Union +from pyramid.request import Request +from pyramid.view import view_config +from tildes.models.user.user import User +from tildes.models.comment import Comment +from tildes.models.pagination import MixedPaginatedResults +from tildes.models.topic import Topic +from tildes.views.api.beta.api_utils import ( + build_error_response, + get_next_and_prev_link, + query_apply_pagination, +) +from tildes.views.api.beta.comment import comment_to_dict +from tildes.views.api.beta.topic import topic_to_dict + + +def _user_to_dict(user: User) -> dict: + """Convert a User object to a dictionary for JSON serialization.""" + return { + "username": user.username, + "joined_at": user.created_time.isoformat(), + "bio_rendered_html": user.bio_rendered_html, + } + + +@view_config(route_name="apibeta.user", openapi=True, renderer="json") +def get_user(request: Request) -> dict: # noqa + """Get a single user with their comment and post history.""" + username = request.openapi_validated.parameters.path.get("username") + limit = request.openapi_validated.parameters.query.get("limit", 20) + before = request.openapi_validated.parameters.query.get("before", None) + after = request.openapi_validated.parameters.query.get("after", None) + + # Maximum number of items to return without history permission + max_items_no_permission = 20 + + try: + query = request.query(User).include_deleted().filter(User.username == username) + user = query.one_or_none() + if not user: + raise ValueError(f"User with name {username} not found") + except ValueError as exc: + return build_error_response(str(exc), field="username") + + if not request.has_permission("view_history", user) and ( + limit > max_items_no_permission or before or after + ): + return build_error_response( + f"You do not have permission to view this user's history after " + f"the first {max_items_no_permission} items. " + f"Please resubmit your request without pagination parameters. " + f"If you submit a limit, it must be less " + f"than or equal to {max_items_no_permission}.", + status=403, + field="limit/before/after", + error_type="AuthorizationError", + ) + + result_sets = [] + # For the main user API endpoint, combine topics and comments + types_to_query: list[Union[type[Topic], type[Comment]]] = [Topic, Comment] + for type_to_query in types_to_query: + query = request.query(type_to_query).filter(type_to_query.user == user) + try: + query = query_apply_pagination( + query, before, after, error_if_no_anchor=True + ) + except ValueError as exc: + return build_error_response(str(exc), field="pagination") + # include removed posts if the viewer has permission + if request.has_permission("view_removed_posts", user): + query = query.include_removed() + query = query.join_all_relationships() + + result_sets.append(query.get_page(limit)) + + combined_results = MixedPaginatedResults(result_sets) + + # Build the JSON history data + processed_results = [] + for item in combined_results.results: + if isinstance(item, Topic): + processed_results.append(topic_to_dict(item)) + elif isinstance(item, Comment): + processed_results.append(comment_to_dict(request, item)) + + # Construct the paging next and previous link if there are more topics + (next_link, prev_link) = get_next_and_prev_link(request, combined_results) + + # Construct the final response JSON object + response = { + "user": _user_to_dict(user), + "history": processed_results, + "pagination": { + "num_items": len(processed_results), + "next_link": next_link, + "prev_link": prev_link, + }, + } + return response + + +@view_config(route_name="apibeta.user_comments", openapi=True, renderer="json") +def get_user_comments(request: Request) -> dict: + """Get comments made by a user.""" + username = request.openapi_validated.parameters.path.get("username") + limit = request.openapi_validated.parameters.query.get("limit", 50) + before = request.openapi_validated.parameters.query.get("before", None) + after = request.openapi_validated.parameters.query.get("after", None) + + try: + query = request.query(User).include_deleted().filter(User.username == username) + user = query.one_or_none() + if not user: + raise ValueError(f"User with name {username} not found") + except ValueError as exc: + return build_error_response(str(exc), field="username") + + if not request.has_permission("view_history", user): + return build_error_response( + "You do not have permission to view this user's comments.", + status=403, + field="N/A", + error_type="AuthorizationError", + ) + + query = request.query(Comment).filter(Comment.user == user) + try: + query = query_apply_pagination(query, before, after, error_if_no_anchor=False) + except ValueError as exc: + return build_error_response(str(exc), field="pagination") + # include removed posts if the viewer has permission + if request.has_permission("view_removed_posts", user): + query = query.include_removed() + query = query.join_all_relationships() + + query_result = query.get_page(limit) + + # Build the JSON history data + processed_comments = [] + for comment in query_result.results: + processed_comments.append(comment_to_dict(request, comment)) + + # Construct the paging next and previous link if there are more comments + (next_link, prev_link) = get_next_and_prev_link(request, query_result) + + # Construct the final response JSON object + response = { + "comments": processed_comments, + "pagination": { + "num_items": len(processed_comments), + "next_link": next_link, + "prev_link": prev_link, + }, + } + return response + + +@view_config(route_name="apibeta.user_topics", openapi=True, renderer="json") +def get_user_topics(request: Request) -> dict: + """Get topics made by a user.""" + username = request.openapi_validated.parameters.path.get("username") + limit = request.openapi_validated.parameters.query.get("limit", 50) + before = request.openapi_validated.parameters.query.get("before", None) + after = request.openapi_validated.parameters.query.get("after", None) + + try: + query = request.query(User).include_deleted().filter(User.username == username) + user = query.one_or_none() + if not user: + raise ValueError(f"User with name {username} not found") + except ValueError as exc: + return build_error_response(str(exc), field="username") + + if not request.has_permission("view_history", user): + return build_error_response( + "You do not have permission to view this user's topics.", + status=403, + field="N/A", + error_type="AuthorizationError", + ) + + query = request.query(Topic).filter(Topic.user == user) + try: + query = query_apply_pagination(query, before, after, error_if_no_anchor=False) + except ValueError as exc: + return build_error_response(str(exc), field="pagination") + # include removed posts if the viewer has permission + if request.has_permission("view_removed_posts", user): + query = query.include_removed() + query = query.join_all_relationships() + + query_result = query.get_page(limit) + + # Build the JSON history data + processed_topics = [] + for topic in query_result.results: + processed_topics.append(topic_to_dict(topic)) + + # Construct the paging next and previous link if there are more topics + (next_link, prev_link) = get_next_and_prev_link(request, query_result) + + # Construct the final response JSON object + response = { + "topics": processed_topics, + "pagination": { + "num_items": len(processed_topics), + "next_link": next_link, + "prev_link": prev_link, + }, + } + return response diff --git a/tildes/tildes/views/api/v0/__init__.py b/tildes/tildes/views/api/v0/__init__.py deleted file mode 100644 index 54b9f00..0000000 --- a/tildes/tildes/views/api/v0/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Contains views for v0 of the JSON API.""" diff --git a/tildes/tildes/views/api/v0/group.py b/tildes/tildes/views/api/v0/group.py deleted file mode 100644 index 0cb1666..0000000 --- a/tildes/tildes/views/api/v0/group.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) 2018 Tildes contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""API v0 endpoints related to groups.""" - -from pyramid.request import Request - -from tildes.api import APIv0 -from tildes.resources.group import group_by_path - - -ONE = APIv0(name="group", path="/groups/{path}", factory=group_by_path) - - -@ONE.get() -def get_group(request: Request) -> dict: - """Get a single group's data.""" - return request.context diff --git a/tildes/tildes/views/api/v0/topic.py b/tildes/tildes/views/api/v0/topic.py deleted file mode 100644 index 5e1536c..0000000 --- a/tildes/tildes/views/api/v0/topic.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2018 Tildes contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""API v0 endpoints related to topics.""" - -from pyramid.request import Request - -from tildes.api import APIv0 -from tildes.resources.topic import topic_by_id36 - - -ONE = APIv0( - name="topic", path="/groups/{path}/topics/{topic_id36}", factory=topic_by_id36 -) - - -@ONE.get() -def get_topic(request: Request) -> dict: - """Get a single topic's data.""" - return request.context diff --git a/tildes/tildes/views/api/v0/user.py b/tildes/tildes/views/api/v0/user.py deleted file mode 100644 index 9a58706..0000000 --- a/tildes/tildes/views/api/v0/user.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) 2018 Tildes contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""API v0 endpoints related to users.""" - -from pyramid.request import Request - -from tildes.api import APIv0 -from tildes.resources.user import user_by_username - - -ONE = APIv0(name="user", path="/users/{username}", factory=user_by_username) - - -@ONE.get() -def get_user(request: Request) -> dict: - """Get a single user's data.""" - return request.context