mirror of
https://gitlab.com/tildes/tildes.git
synced 2026-04-16 06:18:34 +02:00
439
tildes/openapi_beta.yaml
Normal file
439
tildes/openapi_beta.yaml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
1
tildes/tildes/views/api/beta/__init__.py
Normal file
1
tildes/tildes/views/api/beta/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Contains views for the JSON web API."""
|
||||
91
tildes/tildes/views/api/beta/api_utils.py
Normal file
91
tildes/tildes/views/api/beta/api_utils.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# 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,
|
||||
}
|
||||
],
|
||||
)
|
||||
79
tildes/tildes/views/api/beta/comment.py
Normal file
79
tildes/tildes/views/api/beta/comment.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# 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
|
||||
171
tildes/tildes/views/api/beta/topic.py
Normal file
171
tildes/tildes/views/api/beta/topic.py
Normal file
@@ -0,0 +1,171 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# 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
|
||||
217
tildes/tildes/views/api/beta/user.py
Normal file
217
tildes/tildes/views/api/beta/user.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# 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
|
||||
@@ -1 +0,0 @@
|
||||
"""Contains views for v0 of the JSON API."""
|
||||
@@ -1,18 +0,0 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# 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
|
||||
@@ -1,20 +0,0 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# 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
|
||||
@@ -1,18 +0,0 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# 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
|
||||
Reference in New Issue
Block a user