diff --git a/astro.config.mjs b/astro.config.mjs index d82197eb..fe1abeaa 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,7 +5,7 @@ import { remarkDefinitionList, defListHastHandlers, } from "remark-definition-list"; -import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi' +import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi"; // https://astro.build/config export default defineConfig({ @@ -26,8 +26,8 @@ export default defineConfig({ base: "/explorer", label: "API explorer", schema: "./schema.yml", - } - ]) + }, + ]), ], sidebar: [ { @@ -40,19 +40,9 @@ export default defineConfig({ }, { label: "Specifications", - items: [ - { - label: "Introduction", - link: "specs", - }, - { - label: "Subscriptions", - collapsed: true, - autogenerate: { - directory: "specs/subscriptions", - }, - }, - ], + autogenerate: { + directory: "specs", + }, }, ...openAPISidebarGroups, ], @@ -68,7 +58,7 @@ export default defineConfig({ "TabItem", ], }, - "src/components/SponsorCallout.astro" + "src/components/SponsorCallout.astro", ], }), ], diff --git a/src/content/docs/partials/_core-action.mdx b/src/content/docs/partials/_core-action.mdx deleted file mode 100644 index cc3c862b..00000000 --- a/src/content/docs/partials/_core-action.mdx +++ /dev/null @@ -1,3 +0,0 @@ -:::caution[Core action] -This is a **core action**. All server implementations MUST support it. -::: diff --git a/src/content/docs/partials/_core-endpoint.mdx b/src/content/docs/partials/_core-endpoint.mdx deleted file mode 100644 index d832eefb..00000000 --- a/src/content/docs/partials/_core-endpoint.mdx +++ /dev/null @@ -1,3 +0,0 @@ -:::caution[Core endpoint] -This is a **core endpoint**. All server implementations MUST support it. -::: diff --git a/src/content/docs/specs/index.md b/src/content/docs/specs/index.md new file mode 100644 index 00000000..f6c0a99b --- /dev/null +++ b/src/content/docs/specs/index.md @@ -0,0 +1,32 @@ +--- +title: API reference +description: The Open Podcast API is a standard that facilitates the synchronization of podcast data between podcast clients. +sidebar: + order: 1 +--- + +## 1. Overview + +The Open Podcast API is a standard that facilitates the synchronization of podcast data between podcast clients. + +This specification aims to provide comprehensive instructions for client and server developers looking to support the standard. + +## 2. Definitions + +The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +### 2.1 Core and Optional endpoints + +To ensure that the end-user experience is consistent across implementations, the specifications mark endpoints and features as Core (required) and Optional. + +Core +: The feature or endpoint MUST be supported by all clients and servers. + +Optional +: The feature or endpoint is considered to be additional functionality. Clients and servers MAY optionally support any combination of these features. Any project implementing Optional functionality SHOULD inform users about what is supported. + + +## 3. Core endpoints + +- [Subscriptions](/specs/subscriptions) + diff --git a/src/content/docs/specs/index.mdx b/src/content/docs/specs/index.mdx deleted file mode 100644 index 4582eb28..00000000 --- a/src/content/docs/specs/index.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: API Specs -description: All supported API specifications -next: false -prev: false -tableOfContents: false -sidebar: - order: 1 ---- - -:::caution[Important] -All specifications are currently 'in progress'. Breaking changes can occur as we implement specifications and address issues. -::: - -Below you can find the specifications which are already available. We encourage all interested projects offering podcast listening and/or synchronization functionality to adopt and implement defined specifications. We also welcome feedback on these sepcifications. - -## Core and optional functionality - -To ensure that the end-user experience is consistent across implementations, the specifications mark endpoints and features as **Core** (required) and **Optional**. - -**Core** -: The feature or endpoint MUST be supported by all clients and servers. - -**Optional** -: The feature or endpoint is considered to be additional functionality. Clients and servers MAY optionally support any combination of these features. Any project implementing **Optional** functionality SHOULD inform users about what is supported. - -Which features a server supports MUST be exposed through a Capabilities endpoint. - -## Core endpoints - - - - diff --git a/src/content/docs/specs/subscriptions.md b/src/content/docs/specs/subscriptions.md new file mode 100644 index 00000000..b9cb7204 --- /dev/null +++ b/src/content/docs/specs/subscriptions.md @@ -0,0 +1,616 @@ +--- +title: Subscription API specification +descriptions: Use the subscriptions endpoint to manage podcast subscriptions +sidebar: + label: Subscriptions + badge: + text: Experimental + variant: caution +banner: + content: This is a core endpoint. All implementing servers and clients MUST support it. +--- + +Subscriptions represent the relationship between a user and a podcast feed. + +## 1. Introduction + +Subscriptions are at the heart of the Open Podcast API. They represent which feeds a user has subscribed to, both presently and historically. + +The `subscriptions` endpoint is designed to give clients a simple interface for synchronizing a user's podcast subscriptions. It aims to support: + +* Offline-first operation +* Deterministic identifiers +* Idempotent operations +* Efficient incremental synchronization +* Multi-device consistency + +## 2. Motivation + +The Podcast 2.0 specification presents developers with stable identifiers (`podcast:guid`), which are UUIDv5 values that can be calculated from the feed URL using a standard-supplied namespace. However, the original podcast specification makes no such guarantees. This makes implementing cross-device synchronization difficult, as developers need to use unstable fields to determine which feed is being targeted. + +To resolve this, the Open Podcast API makes use of the same deterministic UUID resolution outlined in the Podcast Index documentation[^1] and requires Clients to provide a calculated UUID value with every feed. + +## 3. Conventions used in this document + +### 3.1 Normative language + +The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" in this document are to be interpreted as described in RFC 2119[^2]. + +The following terms are also used throughout this document: + +Client +: Software that sends HTTP requests to a conforming server. + +Server +: An implementation that exposes the endpoints defined in this specification. + +User +: The authenticated principal performing requests. + +Feed +: A shared resource representing a podcast feed. + +Subscription +: A user-owned resource containing details about a User's subscription to a Feed. + +Action +: An operation performed against a subscription resource. + +Cursor +: An opaque token used to resume synchronization. + +### 3.2 Timestamp format + +Timestamps MUST be conform to RFC 3339[^3] and be submitted in UTC. + +### 3.3 Data Serialization + +All request and response bodies MUST be encoded as UTF-8 JSON. + +### 3.4 Identifier formats + +This specification uses the following identifier formats: + +* UUID version 4 for client identifiers as defined in RFC 9562[^4] +* UUID version 5 for deterministic resource identifiers as defined in RFC 9562[^4] +* Base64 encoding for cursors + +## 4. Scope + +This specification defines: + +* Resource identifiers +* Action submission semantics +* Synchronization mechanisms +* Conflict resolution rules +* Client and server behavior + +This document does not define: + +* User authentication mechanisms +* Feed metadata ingestion +* Client user interface behavior + +## 5. System architecture + +### 5.1 Overview + +The system consists of: + +* Client devices +* An HTTP API server + +### 5.2 Offline operation + +Clients MAY operate without network connectivity and queue actions locally. + +Queued actions MUST be transmitted to the server when connectivity is restored. + +### 5.3 Synchronization model + +Synchronization is based on an append-only action log. + +Clients retrieve new actions using a cursor-based incremental synchronization mechanism. + +## 6. UUID calculation + +Feeds are identified using **deterministic UUIDv5 identifiers** derived from podcast feed URLs. +Clients MUST provide a valid UUIDv5 identifier for all feed objects. +This UUID value must be determined by ONE of the following methods, in order of preference: + +1. Using the `podcast:guid` value of the feed's RSS file, if it is a valid UUID OR, +1. Calculating a UUIDv5 value using the normalized `feed_url`. + +To calculate the UUID value, the client MUST do the following: + +1. Normalize the `feed_url` by removing the scheme (for example: `https://`) and all trailing slashes (`/`). +1. Calculate the UUID using the normalized `feed_url` and the podcast namespace UUID: `ead4c236-bf58-58c6-a2c6-a6b28d128cb6`. + +See the Podcast Index's `Guid` documentation for more information.[^1] + +### 6.1 Example + +```py +import uuid +import re + +def calculate_uuid(feed_url): + PODCAST_NAMESPACE = uuid.UUID("ead4c236-bf58-58c6-a2c6-a6b28d128cb6") + sanitized_feed_url = re.sub(r'^[a-zA-Z]+://', '', feed_url).rstrip('/') + return uuid.uuid5(PODCAST_NAMESPACE, sanitized_feed_url) +``` + +Running the above example with the feed URL `"https://podnews.net/rss/"` will yield `9b024349-ccf0-5f69-a609-6b82873eab3c`. + +## 7. Subscription status + +Subscriptions are considered valid even if the User has unsubscribed from the feed. Unsubscribing is a **non-destructive** action that leaves the subscription entry intact. + +A User is "subscribed" to a Feed when they: + +1. Have a Subscription entry for the Feed AND +1. The `unsubscribed_at` timestamp is null. + +Clients may submit an `update` to a Subscription with a null `unsubscribed_at` timestamp to resubscribe a user to a feed. + +## 7. Resource models + +### 7.1 Feed + +A Feed represents a shared logical resource corresponding to a podcast RSS feed. Feeds are uniquely identified by a deterministic UUID derived from the normalized feed URL and a podcast namespace UUID. + +A Feed resource MAY exist independently of any Subscriptions but MAY also be created implicitly when a Subscription is submitted. + +#### Fields + +| Field | Type | Required | Description | +| ------------ | ---------------- | -------- | ------------------------------------------------------- | +| `uuid` | UUID | Yes | Deterministic identifier for the feed | +| `feed_url` | string | Yes | The RSS feed's canonical URL used to calculate the UUID | +| `created_at` | string (RFC3339) | Yes | Server-authoritative creation timestamp | +| `updated_at` | string (RFC3339) | Yes | Server-authoritative update timestamp | + +### 8.2 Subscription + +A Subscription represents a user's subscription to a given Feed. + +Each User MAY have **at most one Subscription per Feed**. + +A Subscription is uniquely identified by the tuple: + +```txt +(user, feed_uuid) +``` + +Clients do not directly access Subscription identifiers. Subscriptions are accessed via the Feed resource. + +#### Fields + +| Field | Type | Required | Description | +| ----------------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------ | +| `subscribed_at` | string (RFC3339) | Yes | Client-provided subscription timestamp, if submitted. Implicitly created by the server if absent | +| `unsubscribed_at` | string (RFC3339) | No | Client-provided unsubscription timestamp, if submitted. | +| `created_at` | string (RFC3339) | Yes | Server-authoritative creation timestamp | +| `updated_at` | string (RFC3339) | Yes | Server-authoritative update timestamp | + +Normative rule: `created_at` and `updated_at` are managed by the server. Clients MAY supply `subscribed_at` and `unsubscribed_at` in requests but it doesn't override the server’s canonical timestamps. + +## 8. Client action submission + +### 8.1 Endpoint + +```http +POST /api/v1/subscriptions +``` + +Clients use this endpoint to submit subscription actions. + +### 8.2 Purpose + +This endpoint supports the submission of `actions` for Subscriptions. Each `action` MUST reference a Feed. + +### 8.3 Supported actions + +Each object in a request payload MUST reference an `action`. The supported actions for this endpoint are: + +`create` +: Create a new subscription for the authenticated User and the referenced Feed + +`update` +: Update the subscription details for an authenticated User and a referenced Feed + +### 8.4 Response statuses + +Each handled item in a POST request to this endpoint MUST be returned in the response. To inform the Client, each object MUST contain a `status` field matching the following enumerable values: + +`created` +: The subscription was created successfully + +`updated` +: The subscription was updated successfully + +`conflict` +: A subscription for the requesting User to the provided Feed exists already + +`duplicate` +: The payload object is a duplicate of another update in the same payload + +`invalid_action` +: The payload object referenced an invalid [action](#83-supported-actions) + +`malformed_feed_uuid` +: The UUID value in the Feed payload is malformed + +`malformed_feed_url` +: The URL in the Feed payload is not a valid URI value + +`transient_server_error` +: The Server could not perform the update due to a transient issue such as database connection issues + +### 8.5 Request format + +Requests sent to this endpoint MUST conform to the following: + +1. All requests MUST be submitted as an array of objects, with at least one and at most 30 items. +1. Each item in the array MUST include all required fields. + +Servers MUST immediately reject any invalid payload with a `400` response. + +| Field | Type | Required | Description | +| ----------------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------- | +| `data` | array | Yes | The array of data submitted to the server | +| `data.uuid` | UUID | Yes | The Client-generated UUIDv4 identifier for the action | +| `data[].action` | string | Yes | The [supported action](#83-supported-actions) being taken against the subscription | +| `data[].feed` | object | Yes | Details about the Feed that the subscription targets | +| `data[].feed.uuid` | UUID | Yes | The calculated UUIDv5 identifier for the Feed | +| `data[].feed.feed_url` | string | Yes | The canonical URL of the feed RSS file | +| `data[].data` | object | Yes | The data object containing subscription information with **at least one** value | +| `data[].data.subscribed_at` | string (RFC3339) | No | The timestamp at which the subscription was created | +| `data[].data.unsubscribed_at` | string (RFC3339) or null | No | The timestamp at which the user unsubscribed from the feed | + + +### 8.6 Response format + +If all fields in the request payload are valid, the Server MUST respond with a `202` status and return a payload with an object corresponding to each `action` submitted. + +| Field | Type | Required | Description | +| --------------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------- | +| `data` | array | Yes | The array of response objects | +| `data.uuid` | UUID | Yes | The Client-generated UUIDv4 identifier for the action | +| `data.status` | string | Yes | The Server-authoritative [response status](#84-response-statuses) | +| `data.received` | string (RFC3339) | Yes | The Server-authoritative timestamp at which the request was received | +| `data[].feed` | object | No | The referenced Feed item for the action | +| `data[].feed.uuid` | UUID | Yes | The calculated UUIDv5 identifier for the feed | +| `data[].feed.feed_url` | string | Yes | The canonical URL of the feed RSS file | +| `data[].feed.created_at` | string (RFC3339) | Yes | The Server-authoritative creation timestamp for the Feed entity | +| `data[].feed.updated_at` | string (RFC3339) | No | The Server-authoritative last update timestamp for the Feed entity | +| `data[].subscription` | object | No | The Subscription entity | +| `data[].subscription.subscribed_at` | string (RFC3339) | No | The timestamp at which the User subscribed to the Feed | +| `data[].subscription.unsubscribed_at` | string (RFC3339) | No | The timestamp at which the User subscribed to the Feed | +| `data[].subscription.created_at` | string (RFC3339) | Yes | The Server-authoritative creation timestamp for the Subscription entity | +| `data[].subscription.updated_at` | string (RFC3339) | Yes | The Server-authoritative update timestamp for the Subscription entity | + +### 8.7 Client behavior + +The Client MUST follow these rules when submitting a request to this endpoint: + +1. The Client MUST NOT submit more than 30 items in a single payload. +1. The Client MUST generate a random UUID for each action in the payload. +1. The Client MUST await a response from the Server before submitting a new request. +1. The Client SHOULD inform the User of any failures that were received in the response. +1. The Client MAY retry items that failed with a status of `transient_server_error`. +1. The Client MUST NOT retry items that failed with a status of `invalid_action`. +1. The Client MUST NOT retry items that failed with a status of `malformed_uuid`. +1. The Client MUST NOT retry items that failed with a status of `malformed_feed_url`. +1. The Client MAY use the `updated_at` timestamp of the Subscription to communicate to a user when the subscription was made active again. + +### 8.8 Server behavior + +The Server MUST keep all action requests in a centralized append-only log format. The Server MAY compact this data to retain only the latest action of a given type. + +The Server MUST update the materialized view of updated entities and return their data in response to updates. + +The Server MUST follow these rules when processing a request to this endpoint: + +1. The Server MUST respond with a `400` error if the payload doesn't contain all required fields. +1. The Server MUST respond with a `400` error if the payload contains **more than 30** or **fewer than 1** items. +1. The Server MUST NOT attempt to process any action that fails validation. +1. The Server MUST process all objects in the response and return a corresponding object in the response. +1. The Server MUST discard any duplicate object from the payload and process only one version of the `action`. +1. The Server MUST create a corresponding object for all submitted `actions` and respond with an array matching the length of the submission. +1. The Server MUST implicitly create a Feed for all actions that reference a non-extant Feed. + +For each Feed: + +1. The Server MUST generate a `created_at` timestamp recording the date and time at which the Feed was added to the system. +1. The Server MUST generate an `updated_at` timestamp recording the date and time at which the Feed was last modified. + +For each Subscription: + +1. The Server MUST generate a `created_at` timestamp recording the date and time at which the Subscription was added to the system. +1. The Server MUST generate an `updated_at` timestamp recording the date and time at which the Subscription was last modified. +1. The Server SHOULD generate a `subscribed_at` timestamp matching the `created_at` timestamp if no `subscribed_at` field is received in the creation payload. +1. The Server MUST NOT add an `unsubscribed_at` timestamp unless one is sent by the Client. + +### 8.9 Example + +```jsonc title="Request" +{ + "data": [ + // Subscribe to a feed + { + "uuid": "329e6b8f-a540-4c6e-9ba0-2996e0352736", + "action": "create", + "feed": { + "uuid": "2fa174b5-2cd8-5c07-b086-fc60045fd9bf", + "feed_url": "https://example.com/feed1.rss/" + }, + "data": { + "subscribed_at": "2026-03-16T05:20:48.000Z" + } + }, + + // Resubscribe to a feed + { + "uuid": "987f1cad-807f-4c00-88aa-277fd470697a", + "action": "update", + "feed": { + "uuid": "34a12041-bdcd-5a3a-be5e-657315db7c44", + "feed_url": "https://example.com/feed2.rss/" + }, + "data": { + "unsubscribed_at": null + } + }, + + // Unsubscribe from a feed + { + "uuid": "4dcf3a4a-42dd-4658-88f6-c71887a04bb8", + "action": "update", + "feed": { + "uuid": "fc4ed290-4621-54fe-b5b4-a001343aeed7", + "feed_url": "https://example.com/feed3.rss/" + }, + "data": { + "unsubscribed_at": "2026-03-16T05:21:48.000Z" + } + }, + + // Invalid action + { + "uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e", + "action": "unsupported", + "feed": { + "uuid": "4790ba1b-1d4e-5f24-886e-7359eb98d52d", + "feed_url": "https://example.com/feed4.rss/" + }, + "data": { + "subscribed_at": "2026-03-16T06:00:02.000Z", + } + }, + + // Invalid feed UUID + { + "uuid": "4c92e4d0-ba1a-497c-83d8-b0c469d4e1be", + "action": "create", + "feed": { + "uuid": "not-a-uuid", + "feed_url": "https://example.com/feed5.rss/" + }, + "data": { + "subscribed_at": "2026-03-16T06:05:02.000Z" + } + } + ] +} +``` + +```jsonc title="Response" +{ + "data": [ + { + "uuid": "4790ba1b-1d4e-5f24-886e-7359eb98d52d", + "status": "created", + "received": "2026-03-16T06:05:02:000Z", + "feed": { + "uuid": "2fa174b5-2cd8-5c07-b086-fc60045fd9bf", + "feed_url": "https://example.com/feed1.rss/", + "created_at": "2026-03-16T06:05:02.000Z", + "updated_at": "2026-03-16T06:05:02.000Z" + }, + "subscription": { + "subscribed_at": "2026-03-16T05:20:48.000Z", + "created_at": "2026-03-16T06:05:02.000Z", + "updated_at": "2026-03-16T06:05:02.000Z" + } + }, + { + "uuid": "987f1cad-807f-4c00-88aa-277fd470697a", + "status": "updated", + "received": "2026-03-16T06:05:02:000Z", + "feed": { + "uuid": "34a12041-bdcd-5a3a-be5e-657315db7c44", + "feed_url": "https://example.com/feed2.rss/", + "created_at": "2026-03-15T03:05:01:000Z", + "updated_at": "2026-03-15T03:05:01:000Z" + }, + "subscription": { + "subscribed_at": "2026-03-15T03:05:01:000Z", + "created_at": "2026-03-15T03:05:01:000Z", + "updated_at": "2026-03-16T06:05:02:000Z" + } + }, + { + "uuid": "4dcf3a4a-42dd-4658-88f6-c71887a04bb8", + "status": "updated", + "received": "2026-03-16T06:05:02:000Z", + "feed": { + "uuid": "fc4ed290-4621-54fe-b5b4-a001343aeed7", + "feed_url": "https://example.com/feed3.rss/", + "created_at": "2026-03-15T03:05:01:000Z", + "updated_at": "2026-03-15T03:05:01:000Z" + }, + "subscription": { + "subscribed_at": "2026-03-15T03:05:01:000Z", + "unsubscribed_at": "2026-03-16T05:21:48.000Z", + "created_at": "2026-03-15T03:05:01:000Z", + "updated_at": "2026-03-16T06:05:02:000Z" + } + }, + { + "uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e", + "status": "invalid_action", + "received": "2026-03-16T06:05:02:000Z", + }, + { + "uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e", + "status": "malformed_feed_uuid", + "received": "2026-03-16T06:05:02:000Z", + } + ] +} +``` + +## 9. Synchronization + +### 9.1 Endpoint + +```http /[\\?\&](.*?)\=/ +GET /api/v1/subscriptions?cursor={cursor}&page_size=30&direction={direction}&include_errors=false +``` + +Clients use this endpoint to request actions that have been submitted to the server since the provided `cursor`. + +### 9.2 Purpose + +This endpoint returns a list of valid and applied actions taken on an authenticated principal's Subscriptions. Clients may use this endpoint to fetch a list of updates to Subscriptions since they last came online. + +### 9.3 Request parameters + +| Parameter | Type | In | Required | Description | +| ---------------- | ------ | ----- | -------- | ------------------------------------------------------------------------------------ | +| `cursor` | string | Query | No | The Base64-encoded cursor to query from | +| `page_size` | number | Query | No | The number of results to return per-page | +| `direction` | string | Query | No | The direction in which to search for results. `ascending` (default) or `descending`. | +| `include_errors` | boolean | Query | No | Whether to include invalid actions (default `false`) | + +### 9.4 Response format + +The Server MUST respond to valid requests with a `200` status. + +| Field | Type | Required | Description | +| --------------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------- | +| `next_cursor` | string | No | The Base64-encoded cursor for the next page of results | +| `prev_cursor` | string | Yes | The Base64-encoded cursor for the current page of results | +| `has_next` | boolean | No | Whether there are more results for the given request | +| `data` | array | Yes | The array of response objects | +| `data.uuid` | UUID | Yes | The Client-generated UUIDv4 identifier for the action | +| `data.status` | string | Yes | The Server-authoritative [response status](#84-response-statuses) | +| `data.received` | string (RFC3339) | Yes | The Server-authoritative timestamp at which the request was received | +| `data[].feed` | object | No | The referenced Feed item for the action | +| `data[].feed.uuid` | UUID | Yes | The calculated UUIDv5 identifier for the feed | +| `data[].feed.feed_url` | string | Yes | The canonical URL of the feed RSS file | +| `data[].feed.created_at` | string (RFC3339) | Yes | The Server-authoritative creation timestamp for the Feed entity | +| `data[].feed.updated_at` | string (RFC3339) | No | The Server-authoritative last update timestamp for the Feed entity | +| `data[].subscription` | object | No | The Subscription entity | +| `data[].subscription.subscribed_at` | string (RFC3339) | No | The timestamp at which the User subscribed to the Feed | +| `data[].subscription.unsubscribed_at` | string (RFC3339) | No | The timestamp at which the User subscribed to the Feed | +| `data[].subscription.created_at` | string (RFC3339) | Yes | The Server-authoritative creation timestamp for the Subscription entity | +| `data[].subscription.updated_at` | string (RFC3339) | Yes | The Server-authoritative update timestamp for the Subscription entity | + +### 9.5 Client behavior + +1. The Client MAY provide any combination of supported query parameters, or none. +1. The Client SHOULD compare results in the response against its internal state to resolve the latest state of the User's Subscriptions. + +### 9.6 Server behavior + +1. The Server MUST discard invalid query parameters and use default parameters. +1. The Server MUST calculate and encode a cursor value for the given request using the provided parameters, or default parameters. +1. The Server MUST NOT return any actions that were not applied due to errors, unless the `include_errors` parameter is `true`. +1. The Server MAY use any method to calculate a cursor provided it meets the following criteria: + 1. The cursor MUST contain **at least one** ordered parameter. For example, `received` timestamp or incremental database IDs. + 1. The cursor MUST NOT contain any sensitive data. + 1. The cursor MUST be Base64-encoded. +1. The Server MUST return actions relating to the authenticated principal only. The Server MUST NOT return any actions associated with other users. +1. The Server SHOULD set sensible default values for any parameters whose default is not explicitly stated in this document. +1. The Server MUST return **at most** the number of results specified in the `page_size` parameter. + +### 9.7 Example + +```sh +curl -X GET "https://opa-server.test/api/v1/subscriptions?page_size=50" +``` + +```jsonc title="Response" +{ + "data": [ + { + "uuid": "4790ba1b-1d4e-5f24-886e-7359eb98d52d", + "status": "created", + "received": "2026-03-16T06:05:02:000Z", + "feed": { + "uuid": "2fa174b5-2cd8-5c07-b086-fc60045fd9bf", + "feed_url": "https://example.com/feed1.rss/", + "created_at": "2026-03-16T06:05:02.000Z", + "updated_at": "2026-03-16T06:05:02.000Z" + }, + "subscription": { + "subscribed_at": "2026-03-16T05:20:48.000Z", + "created_at": "2026-03-16T06:05:02.000Z", + "updated_at": "2026-03-16T06:05:02.000Z" + } + }, + { + "uuid": "987f1cad-807f-4c00-88aa-277fd470697a", + "status": "updated", + "received": "2026-03-16T06:05:02:000Z", + "feed": { + "uuid": "34a12041-bdcd-5a3a-be5e-657315db7c44", + "feed_url": "https://example.com/feed2.rss/", + "created_at": "2026-03-15T03:05:01:000Z", + "updated_at": "2026-03-15T03:05:01:000Z" + }, + "subscription": { + "subscribed_at": "2026-03-15T03:05:01:000Z", + "created_at": "2026-03-15T03:05:01:000Z", + "updated_at": "2026-03-16T06:05:02:000Z" + } + }, + { + "uuid": "4dcf3a4a-42dd-4658-88f6-c71887a04bb8", + "status": "updated", + "received": "2026-03-16T06:05:02:000Z", + "feed": { + "uuid": "fc4ed290-4621-54fe-b5b4-a001343aeed7", + "feed_url": "https://example.com/feed3.rss/", + "created_at": "2026-03-15T03:05:01:000Z", + "updated_at": "2026-03-15T03:05:01:000Z" + }, + "subscription": { + "subscribed_at": "2026-03-15T03:05:01:000Z", + "unsubscribed_at": "2026-03-16T05:21:48.000Z", + "created_at": "2026-03-15T03:05:01:000Z", + "updated_at": "2026-03-16T06:05:02:000Z" + } + }, + { + "uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e", + "status": "invalid_action", + "received": "2026-03-16T06:05:02:000Z", + }, + { + "uuid": "100c7e48-085f-4906-a91e-40c3c4b1a73e", + "status": "malformed_feed_uuid", + "received": "2026-03-16T06:05:02:000Z", + } + ], + "prev_cursor": "aWQ9MXxwYWdlX3NpemU9MzA=", + "has_next": false +} +``` + +[^1]: https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/tags/guid.md +[^2]: https://www.rfc-editor.org/rfc/rfc2119 +[^3]: https://www.rfc-editor.org/rfc/rfc3339 +[^4]: https://www.rfc-editor.org/rfc/rfc9562 diff --git a/src/content/docs/specs/subscriptions/add-new.mdx b/src/content/docs/specs/subscriptions/add-new.mdx deleted file mode 100644 index 422e8920..00000000 --- a/src/content/docs/specs/subscriptions/add-new.mdx +++ /dev/null @@ -1,243 +0,0 @@ ---- -title: Add a new subscription -description: Add a new subscription -sidebar: - order: 2 ---- - -import CoreAction from "@partials/_core-action.mdx"; - - - -```http title="Endpoint" -POST /v1/subscriptions -``` - -This endpoint enables clients to add new subscriptions to the system for the authenticated user. It returns an array of `success` responses for newly added subscriptions, and an array of `failure` responses for subscriptions that couldn't be added. - -| Field | Type | Required? | Description | -| ---------------------- | -------- | --------- | -------------------------------------------------------------------------------------------- | -| `feed_url` | String | Yes | The URL of the podcast RSS feed | -| `guid` | String | Yes | The globally unique ID of the podcast | -| `is_subscribed` | Boolean | Yes | Whether the user is subscribed to the podcast | -| `subscription_changed` | Datetime | Yes | The date on which the `is_subscribed` field was last updated. Presented in [ISO 8601 format] | - -## Request parameters - -The client MUST provide a list of objects containing the following parameters: - -| Field | Type | Required? | Description | -| ---------- | ------ | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `feed_url` | String | Yes | The URL of the podcast RSS feed. The client must provide a protocol (for example: `http` or `https`) and preserve any parameters | -| `guid` | String | No | The GUID found in the podcast RSS feed | - -:::caution[Important] -If a client passes a `guid` this MUST be treated as authoritative by the server. The client MAY pass a `guid` **only** if it is parsed from the podcast RSS feed. -::: - - - - - ```json - { - "subscriptions": [ - { - "feed_url": "https://example.com/rss1" - }, - { - "feed_url": "https://example.com/rss2" - }, - { - "feed_url": "https://example.com/rss3" - }, - { - "feed_url": "https://example.com/rss4", - "guid": "2d8bb39b-8d34-48d4-b223-a0d01eb27d71" - } - ] - } - ``` - - - - - ```xml - - - - https://example.com/feed1 - - - https://example.com/feed2 - - - https://example.com/feed3 - - - https://example.com/feed4 - 2d8bb39b-8d34-48d4-b223-a0d01eb27d71 - - - ``` - - - - -## Server-side behavior - -When new feeds are posted to the server, the server MUST return a success response to the client immediately to acknowledge the request. To ensure that data can be returned immediately, the following flow MUST be followed: - -1. The client sends a payload to the server -2. For each object in the payload, the server does the following: - 1. Checks if there's a `guid` entry in the payload - - If a `guid` is present, the server stores the `guid` for later use - - If no `guid` is present, the server generates a `guid` for later use - 2. Checks to see if there is an existing entry with the same `guid` or `feed_url` - - If an existing entry is found, the server sets the `is_subscribed` field to `true` and updates the `subscription_changed` date to the current date. If the `deleted` field is populated, the field is set to `NULL` to show that the subscription is active - - If no existing entry is found, the server creates a new subscription entry -3. The server returns a success payload containing the subscription information for each object in the request payload. - -![A flowchart diagram of the process](@assets/diagrams/subscriptions/add_new.png) - -### Subscription GUID update - -If the client doesn't send a `guid` in the subscription payload, the server MUST create one immediately to ensure the following: - -1. Each entry has an associated `guid` -2. The client receives a success response as quickly as possible - -Once this is done, the server SHOULD asynchronously verify that there isn't a more authoritative GUID available. The following flow should be used: - -1. The server fetches and parses the RSS feed to search for a [`guid` field in the `podcast` namespace](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid). -2. If a more authoritative `guid` is found, the server must update the subscription entry as follows: - 1. Create a new subscription entry with the new `guid` - 2. Update the `new_guid` field in the existing entry to point to the new `guid` - 3. Update the `guid_changed` field in the existing entry to the current date - -![A diagram of the GUID update process](@assets/diagrams/subscriptions/guid_update.png) - -## Example request - - - - - ```console - $ curl --location '/subscriptions' \ - --header 'Content-Type: application/json' \ - --data '{ - "subscriptions": [ - { - "feed_url": "https://example.com/feed1" - }, - { - "feed_url": "https://example.com/feed2" - }, - { - "feed_url": "https://example.com/feed3" - }, - { - "feed_url": "example.com/feed4", - "guid": "2d8bb39b-8d34-48d4-b223-a0d01eb27d71" - } - ] - }' - ``` - - - - - ```console - $ curl --location '/subscriptions' \ - --header 'Content-Type: application/xml' \ - --data ' - - - https://example.com/feed1 - - - https://example.com/feed2 - - - https://example.com/feed3 - - - example.com/feed4 - 2d8bb39b-8d34-48d4-b223-a0d01eb27d71 - - ' - ``` - - - - -## Example 200 response - - - - - ```json - { - "success": [ - { - "feed_url": "https://example.com/rss1", - "guid": "8d1f8f09-4f50-4327-9a63-639bfb1cbd98", - "is_subscribed": true, - "subscription_changed": "2023-02-23T14:00:00.000Z" - }, - { - "feed_url": "https://example.com/rss2", - "guid": "968cb508-803c-493c-8ff2-9e397dadb83c", - "is_subscribed": true, - "subscription_changed": "2023-02-23T14:00:00.000Z" - }, - { - "feed_url": "https://example.com/rss3", - "guid": "e672c1f4-230d-4ab4-99d3-390a9f835ec1", - "is_subscribed": true, - "subscription_changed": "2023-02-23T14:00:00.000Z" - } - ], - "failure": [ - { - "feed_url": "example.com/rss4", - "message": "No protocol present" - } - ] - } - ``` - - - - - ```xml - - - - https://example.com/rss1 - 8d1f8f09-4f50-4327-9a63-639bfb1cbd98 - true - 2023-02-23T14:00:00.000Z - - - https://example.com/rss2 - 968cb508-803c-493c-8ff2-9e397dadb83c - true - 2023-02-23T14:00:00.000Z - - - https://example.com/rss3 - e672c1f4-230d-4ab4-99d3-390a9f835ec1 - true - 2023-02-23T14:00:00.000Z - - - example.com/rss4 - No protocol present - - - ``` - - - - -[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html diff --git a/src/content/docs/specs/subscriptions/delete.mdx b/src/content/docs/specs/subscriptions/delete.mdx deleted file mode 100644 index 4939f5d4..00000000 --- a/src/content/docs/specs/subscriptions/delete.mdx +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Delete a subscription -description: Fetch the status of a deletion process -sidebar: - order: 6 ---- - -import CoreAction from "@partials/_core-action.mdx"; - - - -```http title="Endpoint" -DELETE /v1/subscriptions/{guid} -``` - -This endpoint allows clients to mark a feed as deleted. This prevents the server from updating the feed in the background and prevents the server from returning any information, such as playback positions, related to the given associated feed. - -## Server-side behavior - -:::caution[Important] -The server MUST enact all cascade deletions using ACID transactions. If the deletion process fails at any point in the transaction, **all** transactions MUST be rolled back to maintain integrity. -::: - -To ensure that `DELETE` requests are handled asynchronously, the server MUST respond to deletion requests immediately with a `202 (Accepted)` status containing a `deletion_id`. This ID MUST correspond to a status object on the server containing details of the deletion process. The client MUST be able to [query the status of a deletion](/specs/subscriptions/status) to check its progress. - -| Parameter | Type | Required? | Description | -| ---------- | ------- | --------- | --------------------------------------------------------------------------------------------------- | -| `id` | Integer | Yes | The ID of the deletion object | -| `success` | Boolean | Yes | Whether or not the deletion was completed successfully | -| `complete` | Boolean | Yes | Whether or not the deletion process has finished | -| `message` | String | No | A status message indicating the current status of the deletion, or any errors that were encountered | - -The following flow MUST be followed: - -1. The client sends a `DELETE` request for a subscription object -2. The server creates a new deletion status object and returns the `deletion_id` in a `202 (Accepted)` response -3. The server attempts to perform a cascade delete on all related items - 1. If a failure occurs at any point in the process, all transactions are rolled back and the status object is updated to show the following: - - `complete`: Must be true - - `success`: Must be `false` - - `message`: Should be updated to contain a meaningful error message - 2. If all deletions are successful, the status object is updated to show the following: - - `complete`: Must be true - - `success`: Must be `true` - - `message`: Should be updated to contain a success message - -If the client attempts to [fetch a deleted subscription](/specs/subscriptions/get-all), the server MUST respond with a `410 (Gone)` status code to indicate the object and its associated data have been deleted. - -![A diagram of the deletion process](@assets/diagrams/subscriptions/delete_subscription.png) - -## Example request - -```console -$ curl --location --request DELETE \ -'/v1/subscriptions/2d8bb39b-8d34-48d4-b223-a0d01eb27d71' -``` - -## Example 202 response - - - - - ```json - { - "deletion_id": 25, - "message": "Deletion request was received and will be processed" - } - ``` - - - - - ```xml - - - 25 - Deletion request was received and will be processed - - ``` - - - diff --git a/src/content/docs/specs/subscriptions/get-all.mdx b/src/content/docs/specs/subscriptions/get-all.mdx deleted file mode 100644 index 69fc7957..00000000 --- a/src/content/docs/specs/subscriptions/get-all.mdx +++ /dev/null @@ -1,294 +0,0 @@ ---- -title: Get all subscriptions -description: Get all subscriptions for a user -sidebar: - order: 3 ---- - -import CoreAction from "@partials/_core-action.mdx"; - - - -```http title="Endpoint" -GET /v1/subscriptions -``` - -This endpoint enables clients to return all subscription information relating to the authenticated user. It returns pagination information and an array of `subscriptions`. - -## Response fields - -### Metadata - -| Field | Type | Required? | Description | -| ---------- | ------ | --------- | ------------------------------------------------ | -| `total` | Number | Yes | The total number of objects returned by the call | -| `page` | Number | Yes | The number of the page returned in the call | -| `per_page` | Number | Yes | The number of results returned per page | -| `next` | String | No | The URL for the next page of results | -| `previous` | String | No | The URL for the previous page of results | - -### Subscription fields - -| Field | Type | Required? | Description | -| ---------------------- | -------------- | --------- | ----------------------------------------------------------------------------------------------------- | -| `feed_url` | String | Yes | The URL of the podcast RSS feed | -| `guid` | String\ | Yes | The globally unique ID of the podcast | -| `is_subscribed` | Boolean | Yes | Whether the user is subscribed to the podcast | -| `subscription_changed` | Datetime | No | The date on which details relating to the subscription last changed. Presented in [ISO 8601 format] | -| `guid_changed` | Datetime | No | The date on which the podcast's `guid` or `new_guid` was last updated. Presented in [ISO 8601 format] | -| `new_guid` | String\ | No | The new GUID associated with the podcast | -| `deleted` | Datetime | No | The date on which the subscription was deleted. Only returned if the field is not `NULL` | - -## Parameters - -The client MAY add the following parameters to their call: - -| Field | Type | In | Required? | Description | -| ---------- | -------- | ----- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `since` | DateTime | Query | No | The date from which the server should return objects. The server only returns entries whose `subscription_changed`, `guid_changed`, or `deleted` fields are greater than this parameter. Expected in [ISO 8601 format] | -| `page` | Number | Query | No | The page of results to be returned by the server. Defaults to `1` if not present | -| `per_page` | Number | Query | No | The number of results to return in each call. Defaults to `50` if not present | - -:::note -If no `since` parameter is provided, the server MUST return all current subscription information. -::: - -## Server-side behavior - -If the entry contains a `new_guid`, the server MUST return the newest `guid` associated with the entry in the response's `new_guid` field. For example: if a subscription has received 2 new `guid`s, the server MUST return: - -- The subscription's `guid` as it was at the date passed in the `since` parameter, or the original entry's `guid` if no `since` parameter is passed -- The subscription's latest `guid` in the `new_guid` field - -This ensures the client has the most up-to-date entry for the subscription. - -![A flowchart demonstrating the GUID checking process](@assets/diagrams/subscriptions/check_guid.png) - -## Client behavior - -The client SHOULD update its local subscription data to match the information returned in the response. On receipt of a deleted subscription, the client SHOULD present the user with the option to remove their local data or [send their local data to the server](/specs/subscriptions/add-new) to reinstate the subscription details. - -### Resolution example - -This example demonstrates how the server resolves a `new_guid` field for a subscription that has received three GUIDs. Here is how the data is represented in the database: - -| `feed_url` | `guid` | `is_subscribed` | `subscription_changed` | `guid_change` | `new_guid` | -| -------------------------- | -------------------------------------- | --------------- | -------------------------- | -------------------------- | -------------------------------------- | -| `https://example.com/rss1` | `64c1593b-5a1e-4e89-b8a3-d91501065e80` | `true` | `2022-03-21T18:45:35.513Z` | `2022-03-21T19:00:00.000Z` | `daac3ce5-7b16-4cf0-8294-86ad71944a64` | -| `https://example.com/rss1` | `daac3ce5-7b16-4cf0-8294-86ad71944a64` | `true` | `2022-03-21T18:45:35.513Z` | `2022-12-23T10:24:14.670Z` | `36a47c4c-4aa3-428a-8132-3712a8422002` | -| `https://example.com/rss1` | `36a47c4c-4aa3-428a-8132-3712a8422002` | `true` | `2022-03-21T18:45:35.513Z` | `2022-12-23T10:24:14.670Z` | | - -#### Scenario 1 - -In this scenario, the client requests all subscriptions and **doesn't** pass a `since` parameter. This means the server passes the **original** GUID in the `guid` field, and the **latest** GUID in the `new_guid`field. - - - - - ```console - $ curl -X 'GET' \ - '/v1/subscriptions?page=1&per_page=5' \ - -H 'accept: application/json' - ``` - - - - - ```console - $ curl -X 'GET' \ - '/v1/subscriptions?page=1&per_page=5' \ - -H 'accept: application/xml' - ``` - - - - - - - - ```json {8, 11} collapse={2-4} - { - "total": 1, - "page": 1, - "per_page": 5, - "subscriptions": [ - { - "feed_url": "https://example.com/rss1", - "guid": "64c1593b-5a1e-4e89-b8a3-d91501065e80", - "is_subscribed": true, - "guid_changed": "2022-12-23T10:24:14.670Z", - "new_guid": "36a47c4c-4aa3-428a-8132-3712a8422002" - } - ] - } - ``` - - - - - ```xml {8, 11} collapse={3-5} - - - 1 - 1 - 5 - - https://example.com/rss1 - 64c1593b-5a1e-4e89-b8a3-d91501065e80 - true - 2022-12-23T10:24:14.670Z - 36a47c4c-4aa3-428a-8132-3712a8422002 - - - ``` - - - - -#### Scenario 2 - -In this scenario, the client requests all subscriptions and specifies a `since` date of `2022-05-30T00:00:00.000Z`. Since the first GUID change occurred before this date, and the second GUID change occurred after this date, the server responds with the **second** GUID in the `guid` field, and the **latest** GUID in the `new_guid` field. - - - - - ```console "since=2022-05-30T00%3A00%3A00.000Z" - $ curl -X 'GET' \ - '/v1/subscriptions?since=2022-05-30T00%3A00%3A00.000Z&page=1&per_page=5' \ - -H 'accept: application/json' - ``` - - - - - ```console "since=2022-05-30T00%3A00%3A00.000Z" - $ curl -X 'GET' \ - '/v1/subscriptions?since=2022-05-30T00%3A00%3A00.000Z&page=1&per_page=5' \ - -H 'accept: application/xml' - ``` - - - - - - - - ```json {8, 11} collapse={2-4} - { - "total": 1, - "page": 1, - "per_page": 5, - "subscriptions": [ - { - "feed_url": "https://example.com/rss1", - "guid": "daac3ce5-7b16-4cf0-8294-86ad71944a64", - "is_subscribed": true, - "guid_changed": "2022-12-23T10:24:14.670Z", - "new_guid": "36a47c4c-4aa3-428a-8132-3712a8422002" - } - ] - } - ``` - - - - - ```xml {8, 11} collapse={3-5} - - - 1 - 1 - 5 - - https://example.com/rss1 - daac3ce5-7b16-4cf0-8294-86ad71944a64 - true - 2022-12-23T10:24:14.670Z - 36a47c4c-4aa3-428a-8132-3712a8422002 - - - ``` - - - - -## Example request - - - - - ```console - $ curl -X 'GET' \ - '/v1/subscriptions?since=2022-04-23T18%3A25%3A34.511Z&page=1&per_page=5' \ - -H 'accept: application/json' - ``` - - - - - ```console - $ curl -X 'GET' \ - '/v1/subscriptions?since=2022-04-23T18%3A25%3A34.511Z&page=1&per_page=5' \ - -H 'accept: application/xml' - ``` - - - - -## Example 200 response - - - - - ```json - { - "total": 2, - "page": 1, - "per_page": 5, - "subscriptions": [ - { - "feed_url": "https://example.com/rss1", - "guid": "31740ac6-e39d-49cd-9179-634bcecf4143", - "is_subscribed": true, - "guid_changed": "2022-09-21T10:25:32.411Z", - "new_guid": "8d1f8f09-4f50-4327-9a63-639bfb1cbd98" - }, - { - "feed_url": "https://example.com/rss2", - "guid": "968cb508-803c-493c-8ff2-9e397dadb83c", - "is_subscribed": false, - "subscription_changed": "2022-04-24T17:53:21.573Z" - } - ] - } - ``` - - - - - ```xml - - - 2 - 1 - 5 - - https://example.com/rss1 - 31740ac6-e39d-49cd-9179-634bcecf4143 - true - 2022-09-21T10:25:32.411Z - 8d1f8f09-4f50-4327-9a63-639bfb1cbd98 - - - https://example.com/rss2 - 968cb508-803c-493c-8ff2-9e397dadb83c - false - 2022-04-24T17:53:21.573Z - - - ``` - - - - -[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html diff --git a/src/content/docs/specs/subscriptions/get-single.mdx b/src/content/docs/specs/subscriptions/get-single.mdx deleted file mode 100644 index 601623ee..00000000 --- a/src/content/docs/specs/subscriptions/get-single.mdx +++ /dev/null @@ -1,126 +0,0 @@ ---- -title: Get a single subscription -description: Get a single subscription for a user -sidebar: - order: 4 ---- - -import CoreAction from "@partials/_core-action.mdx"; - - - -```http title="Endpoint" -GET /v1/subscriptions/{guid} -``` - -This endpoint returns subscription information relating to a specific subscription for the authenticated user. It returns the following information: - -| Field | Type | Required? | Description | -| ---------------------- | -------------- | --------- | ----------------------------------------------------------------------------------------------------- | -| `feed_url` | String | Yes | The URL of the podcast RSS feed | -| `guid` | String\ | Yes | The globally unique ID of the podcast | -| `is_subscribed` | Boolean | Yes | Whether the user is subscribed to the podcast or not | -| `subscription_changed` | Datetime | No | The date on which the `is_subscribed` field was last updated. Presented in [ISO 8601 format] | -| `guid_changed` | Datetime | No | The date on which the podcast's `guid` or `new_guid` was last updated. Presented in [ISO 8601 format] | -| `new_guid` | String\ | No | The new GUID associated with the podcast | -| `deleted` | Datetime | No | The date on which the subscription was deleted. Only returned if the field is not `NULL` | - -## Parameters - -The client MUST send the subscription's `guid` in the path of the request. - -## Server-side behavior - -If the entry contains a `new_guid`, the server MUST return the newest `guid` associated with the entry in the response's `new_guid` field. For example: if a subscription has received 2 new `guid`s, the server MUST return: - -- The subscription's `guid` passed in the request path -- The subscription's latest `guid` in the `new_guid` field - -This ensures the client has the most up-to-date entry for the subscription. - -![A flowchart demonstrating the GUID checking process](@assets/diagrams/subscriptions/check_guid.png) - -## Client behavior - -The client SHOULD update its local subscription data to match the information returned in the response. On receipt of a deleted subscription, the client SHOULD present the user with the option to remove their local data or [send their local data to the server](/specs/subscriptions/add-new) to reinstate the subscription details. - -## Example request - - - - - ```console - $ curl -X 'GET' \ - '/v1/subscriptions/968cb508-803c-493c-8ff2-9e397dadb83c' \ - -H 'accept: application/json' - ``` - - - - - ```console - $ curl -X 'GET' \ - '/v1/subscriptions/968cb508-803c-493c-8ff2-9e397dadb83c' \ - -H 'accept: application/xml' - ``` - - - - -## Example 200 response - - - - - ```json - { - "feed_url": "https://example.com/feed2", - "guid": "968cb508-803c-493c-8ff2-9e397dadb83c", - "is_subscribed": true - } - ``` - - - - - ```xml - - - https://example.com/feed2 - 968cb508-803c-493c-8ff2-9e397dadb83c - true - - ``` - - - - -## Example 410 response - -If a subscription has been [deleted](/specs/subscriptions/delete), the server must respond with a `410 (Gone)` response to inform the client. - - - - - ```json - { - "code": 410, - "message": "Subscription has been deleted" - } - ``` - - - - - ```xml - - - 410 - Subscription has been deleted - - ``` - - - - -[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html diff --git a/src/content/docs/specs/subscriptions/index.mdx b/src/content/docs/specs/subscriptions/index.mdx deleted file mode 100644 index a6975902..00000000 --- a/src/content/docs/specs/subscriptions/index.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Subscriptions endpoint -description: An endpoint for syncing subscriptions between devices. -prev: false -sidebar: - label: Overview - order: 1 ---- - -import CoreEndpoint from "@partials/_core-endpoint.mdx"; - - - -The subscriptions endpoint is used to synchronize subscriptions between a server and connected clients. The server is treated as the authoritative source for subscription information. Clients can query the endpoint by specifying the datetime from which they want to fetch changes to ensure they only fetch information that is relevant to them since their last sync. - -Subscriptions represent the feeds a user has subscribed to. A subscription object stores essential information about each subscription and acts as an index that links other activity information together. - -## Important data fields - -| Field | Type | Nullable? | Description | -| ---------------------- | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `feed_url` | String | No | The URL of the podcast RSS feed | -| `guid` | String\ | No | The globally unique ID of the podcast | -| `is_subscribed` | Boolean | No | Whether the user is subscribed to the podcast | -| `subscription_changed` | Datetime | No | The date on which the `is_subscribed` field was last updated. Presented in [ISO 8601 format] | -| `guid_changed` | Datetime | No | The date on which the podcast's `guid` or `new_guid` was last updated. Presented in [ISO 8601 format] | -| `new_guid` | String\ | Yes | The new GUID associated with the podcast | -| `deleted` | Datetime | Yes | The date on which data associated with the subscription was deleted by the user. This field is used to determine whether a `410 (Gone)` response should be returned | - -:::note[Tombstoning] -Servers SHOULD hold all previous `guid` and `feed_url` field data with a link to the succeeding data (such that a path of values can be followed) or with a link to the most recent data. This enables the server to handle situations in which clients submit old data. For example: - -- A user finds a podcast, whose URL had changed, and adds the old URL in the app. Because the client doesn't have the old URL in its database, it recognizes the podcast as **new** and POSTs the `feed_url` to the `/subscriptions` endpoint. If the user is already subscribed to the podcast (with the current feed URL) this would lead to a duplicate subscription. -- A user has a device that they didn't use for a very long time. In that time, a podcaster added a GUID in their feed, leading to updated data in this field. When the client connects to the server again to pull all episode changes since the last connection, it retrieves episodes with their current subscription `guid`. The client won't recognize the subscription and fail to update the status of episodes. - ::: - -[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html diff --git a/src/content/docs/specs/subscriptions/status.mdx b/src/content/docs/specs/subscriptions/status.mdx deleted file mode 100644 index 91b59944..00000000 --- a/src/content/docs/specs/subscriptions/status.mdx +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: Deletion status endpoint -description: Fetch the status of a deletion process -sidebar: - order: 7 ---- - -import CoreAction from "@partials/_core-action.mdx"; - - - -```http title="Endpoint" -GET /v1/deletions/{id} -``` - -This endpoint enables clients to query the status of a [deletion](/specs/subscriptions/delete). When a client sends a `DELETE` request, the server MUST respond with a `deletion_id` that can be used with this endpoint to check whether a deletion has been successfully actioned. - -| Parameter | Type | Required? | Description | -| ------------- | ------- | --------- | --------------------------------------------------------------------------------------------------- | --------- | --------- | -| `deletion_id` | Integer | Yes | The ID of the deletion object | -| `status` | String | Yes | A status message indicating the status of the deletion. Available values: `SUCCESS` | `FAILURE` | `PENDING` | -| `message` | String | No | A status message indicating the current status of the deletion, or any errors that were encountered | - -## Parameters - -The client MUST send the deletion's `id` in the path of the request. - -## Example request - - - - - ```console - $ curl -X 'GET' \ - '/v1/deletions/25' \ - -H 'accept: application/json' - ``` - - - - - ```console - $ curl -X 'GET' \ - '/v1/deletions/25' \ - -H 'accept: application/xml' - ``` - - - - -## Example 200 response - -The server MUST send a `200 (Success)` if it can fetch a status object without issue. This response MUST contain information about the `deletion_id` passed in the query path. - -### Successful deletion - - - - - ```json {3} - { - "deletion_id": 25, - "status": "SUCCESS", - "message": "Subscription deleted successfully" - } - ``` - - - - - ```xml {3} - - - 25 - SUCCESS - Subscription deleted successfully - - ``` - - - - -### Pending deletion - - - - - ```json {3} - { - "deletion_id": 25, - "status": "PENDING", - "message": "Deletion is pending" - } - ``` - - - - - ```xml {3} - - - 25 - PENDING - Deletion is pending - - ``` - - - - -### Failed deletion - - - - - ```json {3} - { - "deletion_id": 25, - "status": "FAILURE", - "message": "The deletion process encountered an error and was rolled back" - } - ``` - - - - - ```xml {3} - - - 25 - FAILURE - The deletion process encountered an error and was rolled back - - ``` - - - diff --git a/src/content/docs/specs/subscriptions/update.mdx b/src/content/docs/specs/subscriptions/update.mdx deleted file mode 100644 index d7bb440a..00000000 --- a/src/content/docs/specs/subscriptions/update.mdx +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: Update a subscription -description: Update details about a subscription -sidebar: - order: 5 ---- - -import CoreAction from "@partials/_core-action.mdx"; - - - -```http title="Endpoint" -PATCH /v1/subscriptions/{guid} -``` - -This endpoint allows clients to update information about a subscription. The client MAY update the following information: - -- The podcast's GUID -- The podcast's feed URL -- An update to the subscription status for the user - -This endpoint returns the following information: - -| Field | Type | Required? | Description | -| ---------------------- | -------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `new_feed_url` | String | No | The URL of the podcast RSS feed. Only returned if the `feed_url` field was updated by the request | -| `is_subscribed` | Boolean | No | Whether the user is subscribed to the podcast or not. Only returned if the `is_subscribed` field was updated by the request | -| `subscription_changed` | Datetime | No | The date on which the `is_subscribed`or `feed_url` field was last updated. Presented in [ISO 8601 format]. Only returned if the `is_subscribed` field was updated by the request | -| `guid_changed` | Datetime | No | The date on which the podcast's GUID was last updated. Presented in [ISO 8601 format]. Only returned if the `guid` field was updated by the request | -| `new_guid` | String\ | No | The new GUID associated with the podcast. Only returned if the `guid` field was updated by the request | - -## Parameters - -The client MUST pass the subscription GUID in the query path and add at least one field update in the request body. - -| Parameter | Type | In | Required? | Description | -| --------------- | --------------- | ----- | --------- | ------------------------------------------------------------ | -| `guid` | String | Query | Yes | The GUID of the subscription object that needs to be updated | -| `new_feed_url` | String | Body | No | The URL of the new RSS feed for the subscription | -| `new_guid` | String \ | Body | No | The new GUID of the podcast | -| `is_subscribed` | Boolean | Body | No | Whether the user is subscribed to the podcast or not | - -## Server-side behavior - -On receipt of a PATCH request for a subscription, the server MUST do the following: - -1. If the subscription in the request has a `new_guid` specified in the database, follow the `new_guid` chain to find the **latest** version of the subscription -2. If the request contains a `new_feed_url` parameter: - 1. Update the subscription entry's `feed_url` field to the new value - 2. Update the subscription entry's `subscription_changed` field to the current date -3. If the request contains a `new_guid` parameter: - 1. Check if the GUID is already present in the system - 2. If the GUID is already present, update the subscription entry's `new_guid` field to point to the existing entry - 3. If the GUID isn't already present, create a new subscription entry and update the existing entry's `new_guid` field to point to the newly created entry - 4. Update the subscription entry's `guid_changed` to the current date -4. If the request contains an `is_subscribed` parameter: - 1. Update the subscription entry's `is_subscribed` to the new value - 2. Update the subscription entry's `subscription_changed` field to the current date -5. Return a summary of the changes - -![A flowchart of the subscription update process](@assets/diagrams/subscriptions/update_subscription.png) - -## Example request - - - - - ```console - $ curl --location --request PATCH '/subscriptions/2d8bb39b-8d34-48d4-b223-a0d01eb27d71' \ - --header 'Content-Type: application/json' \ - --data '{ - "new_feed_url": "https://example.com/rss5", - "new_guid": "965fcecf-ce04-482b-b57c-3119b866cc61", - "is_subscribed": false - }' - ``` - - - - - ```console - $ curl --location --request PATCH '/subscriptions/2d8bb39b-8d34-48d4-b223-a0d01eb27d71' \ - --header 'Content-Type: application/xml' \ - --data ' - - https://example.com/rss5 - 965fcecf-ce04-482b-b57c-3119b866cc61 - false - ' - ``` - - - - -## Example 200 response - - - - - ```json - { - "new_feed_url": "https://example.com/rss5", - "is_subscribed": false, - "subscription_changed": "2023-02-23T14:41:00.000Z", - "guid_changed": "2023-02-23T14:41:00.000Z", - "new_guid": "965fcecf-ce04-482b-b57c-3119b866cc61" - } - ``` - - - - - ```xml - - - https://example.com/rss5 - false - 2023-02-23T14:41:00.000Z - 2023-02-23T14:41:00.000Z - 965fcecf-ce04-482b-b57c-3119b866cc61 - - ``` - - - - -[ISO 8601 format]: https://www.iso.org/iso-8601-date-and-time-format.html