From b38145fd4004f2d6b8ad0194c787deb35ceab50e Mon Sep 17 00:00:00 2001 From: Origo Date: Mon, 13 Jul 2026 11:23:38 +0800 Subject: [PATCH] feat: define ORSP 1.1 discovery profile --- OEP-0001-discovery.md | 55 +++++++++++ README.es.md | 11 ++- README.ja.md | 10 +- README.ko.md | 10 +- README.md | 24 ++++- README.zh-CN.md | 25 +++-- README.zh-TW.md | 10 +- SPECIFICATION.md | 152 +++++++++++++++++++++++++----- examples/dart_server.dart | 103 +++++++++++++++++++- examples/open-reading-source.json | 12 ++- openapi.yaml | 106 ++++++++++++++++++++- schemas/discovery.schema.json | 27 +++++- scripts/registry_tool.py | 4 +- scripts/validate_contracts.py | 8 ++ tool/conformance_test.dart | 66 ++++++++++++- 15 files changed, 572 insertions(+), 51 deletions(-) create mode 100644 OEP-0001-discovery.md diff --git a/OEP-0001-discovery.md b/OEP-0001-discovery.md new file mode 100644 index 0000000..3089728 --- /dev/null +++ b/OEP-0001-discovery.md @@ -0,0 +1,55 @@ +# OEP-0001: Standard discovery and browsing + +Status: **Candidate** +Target: **ORSP 1.1** + +## Problem + +ORSP 1.0 lets a reader search only after the user already knows a title or +author. Independently implemented readers cannot offer interoperable featured +shelves, category navigation, or latest-update feeds without source-specific +extensions. + +## Proposal + +Reserve three optional standard capabilities and endpoint relations: + +| Capability | Endpoint | Response | +| --- | --- | --- | +| `discover` | `GET v1/discover` | Curated sections containing book objects | +| `categories` | `GET v1/categories` | Stable category IDs and display names | +| `browse` | `GET v1/browse` | Search-style pagination by category or sort | + +The standardized browse sorts are `latest` and `popular`. Sources may implement +any optional capability independently except that `categories` requires +`browse`, because category IDs are defined as browse inputs. + +## Compatibility + +- The four ORSP 1.0 core endpoints and object meanings are unchanged. +- Existing 1.0 sources remain conforming core sources and require no changes. +- Existing 1.x clients already ignore unknown capability tokens. +- A client exposes discovery UI only after the corresponding capability is + advertised; otherwise it falls back to core search. +- All new response objects use existing book metadata and forward-compatible + unknown-member handling. + +## Security and privacy + +The new endpoints are unauthenticated, safe, idempotent `GET` operations under +the same response-size, redirect, caching, SSRF, and untrusted-input rules as +the core profile. ORSP 1.1 does not define personal profiles or personalized +recommendations. Sources must not imply that a curated section is personalized +unless that behavior is documented outside the protocol. + +## Implementation evidence + +- The Open Reading Flutter client implements capability-gated discovery with + fallback to core search. +- This repository's Dart reference source implements all three capabilities. +- The conformance suite covers positive discovery/category/browse flows and + invalid browse sort behavior. + +Independent source and reader implementations are still required before the +candidate can be declared a stable release under the governance acceptance +gates. diff --git a/README.es.md b/README.es.md index f55b2a6..9450256 100644 --- a/README.es.md +++ b/README.es.md @@ -30,6 +30,15 @@ GET {apiBaseUrl}v1/books/{bookId}/chapters?page=1&pageSize=100 GET {apiBaseUrl}v1/books/{bookId}/chapters/{chapterId} ``` +La versión 1.1 también define operaciones opcionales de descubrimiento, +categorías y exploración: + +```text +GET {apiBaseUrl}v1/discover +GET {apiBaseUrl}v1/categories +GET {apiBaseUrl}v1/browse?category={categoryId}&sort=latest&page=1&pageSize=20 +``` + Consulta la [especificación](SPECIFICATION.md) y la definición [OpenAPI 3.1](openapi.yaml) para conocer todos los campos y errores. @@ -57,7 +66,7 @@ emulador de Android. ## Estado y uso responsable -La versión `1.0` es un borrador de estabilización. El flujo básico de lectura ya +La versión `1.1` es un borrador candidato. El flujo básico de lectura ya ha sido verificado, pero se aceptan propuestas sobre autenticación, sincronización, paginación y formatos de contenido antes de declarar una versión estable. diff --git a/README.ja.md b/README.ja.md index a7788cb..64e6351 100644 --- a/README.ja.md +++ b/README.ja.md @@ -29,6 +29,14 @@ GET {apiBaseUrl}v1/books/{bookId}/chapters?page=1&pageSize=100 GET {apiBaseUrl}v1/books/{bookId}/chapters/{chapterId} ``` +バージョン 1.1 では、任意のディスカバリー、カテゴリ、ブラウズ API も定義します。 + +```text +GET {apiBaseUrl}v1/discover +GET {apiBaseUrl}v1/categories +GET {apiBaseUrl}v1/browse?category={categoryId}&sort=latest&page=1&pageSize=20 +``` + フィールドとエラー処理の詳細は、[仕様書](SPECIFICATION.md)および [OpenAPI 3.1 定義](openapi.yaml)を参照してください。 @@ -55,7 +63,7 @@ PC では `http://127.0.0.1:8787`、Android エミュレーターでは ## ステータスと責任ある利用 -バージョン `1.0` は安定化ドラフトです。基本的な読書フローは検証済みですが、安定版の +バージョン `1.1` は候補ドラフトです。基本的な読書フローは検証済みですが、安定版の 公開前に、認証、同期、ページング、コンテンツ形式についてのフィードバックを歓迎します。 ORSP は、オリジナル、パブリックドメイン、または正規に許諾されたコンテンツを対象とします。 diff --git a/README.ko.md b/README.ko.md index 12f6eeb..adf68e4 100644 --- a/README.ko.md +++ b/README.ko.md @@ -28,6 +28,14 @@ GET {apiBaseUrl}v1/books/{bookId}/chapters?page=1&pageSize=100 GET {apiBaseUrl}v1/books/{bookId}/chapters/{chapterId} ``` +버전 1.1은 선택적 탐색, 카테고리 및 둘러보기 엔드포인트도 정의합니다. + +```text +GET {apiBaseUrl}v1/discover +GET {apiBaseUrl}v1/categories +GET {apiBaseUrl}v1/browse?category={categoryId}&sort=latest&page=1&pageSize=20 +``` + 필드와 오류 처리에 대한 자세한 내용은 [프로토콜 명세](SPECIFICATION.md)와 [OpenAPI 3.1 문서](openapi.yaml)를 참고하세요. @@ -54,7 +62,7 @@ PC에서는 `http://127.0.0.1:8787`, Android 에뮬레이터에서는 ## 상태 및 책임 있는 사용 -프로토콜 `1.0`은 안정화 초안입니다. 핵심 읽기 흐름은 검증되었지만 안정 버전 발표 전까지 +프로토콜 `1.1`은 후보 초안입니다. 핵심 읽기 흐름은 검증되었지만 안정 버전 발표 전까지 인증, 동기화, 페이지 처리 및 콘텐츠 형식에 대한 의견을 환영합니다. ORSP는 창작물, 퍼블릭 도메인 및 정식 허가를 받은 콘텐츠를 위한 것입니다. 접근 제어를 diff --git a/README.md b/README.md index 57f5d9e..e7ad596 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Every source publishes a discovery document: GET /.well-known/open-reading-source.json ``` -It then implements four standard API operations: +It implements four core reading operations: ```text GET {apiBaseUrl}v1/search?q={query}&page=1&pageSize=20 @@ -34,21 +34,34 @@ GET {apiBaseUrl}v1/books/{bookId}/chapters?page=1&pageSize=100 GET {apiBaseUrl}v1/books/{bookId}/chapters/{chapterId} ``` +ORSP 1.1 sources may additionally advertise a standard discovery profile: + +```text +GET {apiBaseUrl}v1/discover +GET {apiBaseUrl}v1/categories +GET {apiBaseUrl}v1/browse?category={categoryId}&sort=latest&page=1&pageSize=20 +``` + These endpoint relations are resolved against the discovered `apiBaseUrl`. -ORSP 1.0 Core Reading sources implement all four capabilities. +ORSP 1.1 Core Reading sources implement all four core capabilities. Discovery, +categories, and browsing are optional and capability-gated, so existing ORSP +1.0 sources remain compatible. Example discovery document: ```json { "protocol": "open-reading-source", - "protocolVersion": "1.0", + "protocolVersion": "1.1", "id": "org.example.public-books", "name": "Example Public Books", "description": "Public-domain books maintained by Example.org", "apiBaseUrl": "https://books.example.org/api/", "languages": ["en"], - "capabilities": ["search", "detail", "catalog", "content"] + "capabilities": [ + "search", "discover", "categories", "browse", + "detail", "catalog", "content" + ] } ``` @@ -68,6 +81,7 @@ ORSP-compatible reader. Android emulators should use `http://10.0.2.2:8787`. ```text SPECIFICATION.md Normative protocol specification +OEP-0001-discovery.md ORSP 1.1 discovery proposal and compatibility analysis openapi.yaml OpenAPI 3.1 API description schemas/discovery.schema.json Discovery-document JSON Schema examples/dart_server.dart Runnable reference source @@ -79,7 +93,7 @@ registry/ PR-based community source registry ## Status -Protocol version `1.0` is a stabilization draft. Its compatibility rules, +Protocol version `1.1` is a candidate draft. Its compatibility rules, machine-readable contracts, reference source, and core conformance tests are now developed together. Independent reader and source implementations are welcome before the specification is declared stable. diff --git a/README.zh-CN.md b/README.zh-CN.md index a415801..96cea16 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,17 +42,20 @@ GET /.well-known/open-reading-source.json ```json { "protocol": "open-reading-source", - "protocolVersion": "1.0", + "protocolVersion": "1.1", "id": "org.example.public-books", "name": "示例公共图书源", "description": "由 Example.org 维护的公共领域图书", "apiBaseUrl": "https://books.example.org/api/", "languages": ["zh-CN"], - "capabilities": ["search", "detail", "catalog", "content"] + "capabilities": [ + "search", "discover", "categories", "browse", + "detail", "catalog", "content" + ] } ``` -协议 1.0 定义四类核心接口: +协议 1.1 保留四类核心接口: ```text GET {apiBaseUrl}v1/search?q={query}&page=1&pageSize=20 @@ -61,8 +64,17 @@ GET {apiBaseUrl}v1/books/{bookId}/chapters?page=1&pageSize=100 GET {apiBaseUrl}v1/books/{bookId}/chapters/{chapterId} ``` -这些相对路径必须基于发现文档中的 `apiBaseUrl` 解析。ORSP 1.0 Core Reading 书源必须 -实现全部四项能力。完整字段与错误处理要求请阅读 +支持内容发现的书源还可以声明三个可选标准能力: + +```text +GET {apiBaseUrl}v1/discover +GET {apiBaseUrl}v1/categories +GET {apiBaseUrl}v1/browse?category={categoryId}&sort=latest&page=1&pageSize=20 +``` + +这些相对路径必须基于发现文档中的 `apiBaseUrl` 解析。ORSP 1.1 Core Reading 书源必须 +实现全部四项核心能力;发现、分类和浏览按能力声明启用,旧的 1.0 书源无需修改也可以 +继续搜索和阅读。完整字段与错误处理要求请阅读 [协议规范](SPECIFICATION.md),接口模型可查看 [OpenAPI 3.1 文档](openapi.yaml)。 ## 快速运行测试书源 @@ -106,6 +118,7 @@ HTML 正文应被视为不可信输入,客户端在渲染前应进行清理。 ```text SPECIFICATION.md 规范性协议说明 +OEP-0001-discovery.md ORSP 1.1 发现能力提案与兼容性分析 openapi.yaml OpenAPI 3.1 接口定义 schemas/discovery.schema.json 发现文档 JSON Schema examples/dart_server.dart 可运行的 Dart 参考书源 @@ -118,7 +131,7 @@ registry/ 基于 PR 的社区书源目录 ## 当前状态与兼容性 -协议版本 `1.0` 目前是稳定化草案。兼容性规则、机器可读契约、参考书源和核心一致性测试 +协议版本 `1.1` 目前是候选草案。兼容性规则、机器可读契约、参考书源和核心一致性测试 会同步演进。在宣布稳定版本之前,欢迎独立阅读器和书源实现参与互操作验证。 可以运行官方核心测试: diff --git a/README.zh-TW.md b/README.zh-TW.md index 5a53171..358e80a 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -37,6 +37,14 @@ GET {apiBaseUrl}v1/books/{bookId}/chapters?page=1&pageSize=100 GET {apiBaseUrl}v1/books/{bookId}/chapters/{chapterId} ``` +協議 1.1 亦提供可選的探索、分類與瀏覽端點: + +```text +GET {apiBaseUrl}v1/discover +GET {apiBaseUrl}v1/categories +GET {apiBaseUrl}v1/browse?category={categoryId}&sort=latest&page=1&pageSize=20 +``` + 完整欄位及錯誤處理請參閱[協議規範](SPECIFICATION.md)與 [OpenAPI 3.1 文件](openapi.yaml)。 @@ -63,7 +71,7 @@ dart run examples/dart_server.dart ## 狀態與使用邊界 -協議 `1.0` 目前是穩定化草案。核心讀取流程已完成驗證,但在穩定版本發布前仍歡迎 +協議 `1.1` 目前是候選草案。核心讀取流程已完成驗證,但在穩定版本發布前仍歡迎 對認證、同步、分頁及內容格式提出建議。 ORSP 僅適用於原創、公共領域或合法授權內容。請勿用於繞過存取控制、違反服務條款或 diff --git a/SPECIFICATION.md b/SPECIFICATION.md index fdff2ef..ca995a4 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -1,6 +1,6 @@ -# Open Reading Source Protocol 1.0 +# Open Reading Source Protocol 1.1 -Status: **Stabilization draft** +Status: **Candidate draft** The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119 and RFC 8174 when, and @@ -9,18 +9,19 @@ only when, they appear in all capitals. ## 1. Scope and design goals Open Reading Source Protocol (ORSP) defines a safe HTTP boundary between a -reading client and an independently operated content source. ORSP 1.0 covers: +reading client and an independently operated content source. ORSP 1.1 covers: - source discovery; - book search and metadata; +- optional curated discovery, categories, and browsing; - chapter catalogs; - chapter content retrieval. -ORSP 1.0 covers public HTTP(S) sources that do not require user authentication. +ORSP 1.1 covers public HTTP(S) sources that do not require user authentication. It does not define executable scraping rules. A source MAY use an authorized adapter internally, but clients only receive data described by this protocol. -The core reading profile is intentionally small. A conforming ORSP 1.0 source +The core reading profile is intentionally small. A conforming ORSP 1.1 source MUST implement the complete discovery, search, detail, catalog, and content path. Optional features are advertised as capabilities and MUST NOT change the meaning of core fields or endpoints. @@ -64,7 +65,7 @@ Required discovery members: | Member | Requirement | | --- | --- | | `protocol` | MUST equal `open-reading-source` | -| `protocolVersion` | ORSP `MAJOR.MINOR`; this specification uses `1.0` | +| `protocolVersion` | ORSP `MAJOR.MINOR`; this specification uses `1.1` | | `id` | Stable globally unique source identifier | | `name` | Human-readable source name | | `apiBaseUrl` | Absolute HTTP(S) URL ending in `/` | @@ -72,6 +73,20 @@ Required discovery members: Core capability tokens are `search`, `detail`, `catalog`, and `content`. +ORSP 1.1 defines three optional standard capability tokens: + +| Capability | Endpoint relation | Purpose | +| --- | --- | --- | +| `discover` | `v1/discover` | Curated shelves such as featured or popular books | +| `categories` | `v1/categories` | Stable category identifiers and display names | +| `browse` | `v1/browse` | Paginated browsing by category or standard sort | + +A source declaring `categories` MUST also declare `browse`, because category +identifiers are defined for use with the browse endpoint. A source MAY declare +`browse` without `categories`, for example to expose only a latest-updates +feed. These capabilities are optional: their absence MUST NOT affect core +search or reading behavior. + Extension capability tokens MUST use a collision-resistant namespace in the form `reverse.domain:name`, for example `org.example:audio`. An extension token MUST NOT use the `orsp` namespace unless it is defined by a future ORSP @@ -138,7 +153,7 @@ how to interpret it. - Core endpoints use `GET` and MUST be safe and idempotent. - Clients SHOULD send `Accept: application/json`. - Clients SHOULD send `X-Open-Reading-Protocol` with their highest supported - ORSP 1.x version, for example `1.0`. + ORSP 1.x version, for example `1.1`. - Sources MAY return `X-Open-Reading-Protocol` containing the source protocol version. - Unsupported methods MUST return `405 Method Not Allowed` and SHOULD include @@ -158,10 +173,11 @@ untrusted input. ### 5.1 Caching -Discovery, detail, catalog, and content responses SHOULD provide an `ETag` or -appropriate `Cache-Control`/`Last-Modified` headers. When a client sends -`If-None-Match` with a current entity tag, a source SHOULD return `304 Not -Modified` with no response body. +Discovery documents, discovery shelves, categories, browse pages, detail, +catalog, and content responses SHOULD provide an `ETag` or appropriate +`Cache-Control`/`Last-Modified` headers. When a client sends `If-None-Match` +with a current entity tag, a source SHOULD return `304 Not Modified` with no +response body. An entity tag identifies the complete selected representation. Sources MUST change it when a response changes in a way visible to a client. @@ -215,7 +231,95 @@ Book objects MUST include `id` and `title`. Other members are optional. `language`, when present, MUST be a BCP 47 tag. `status` is one of `ongoing`, `completed`, `hiatus`, or `unknown`. -## 7. Book details +## 7. Curated discovery (optional) + +Sources declaring `discover` MUST implement: + +```text +GET v1/discover +``` + +Response: + +```json +{ + "sections": [ + { + "id": "featured", + "title": "Featured", + "items": [ + { + "id": "book-1001", + "title": "Example Book", + "author": "Example Author" + } + ] + } + ] +} +``` + +`sections` is required. Each section MUST contain `id`, `title`, and `items`. +Section IDs follow the opaque wire-ID rules in Section 4.2, are scoped to the +source, and SHOULD remain stable when the display title changes. Section titles +are human-readable and MAY be localized according to the source's content +policy. Empty sections SHOULD be omitted. + +Each section MAY contain at most 100 books. Book objects use the same model as +search and detail. Section ordering and item ranking are source-defined but +SHOULD be deterministic for an unchanged dataset. Clients MUST NOT interpret a +section as personalized unless the source explicitly documents that behavior; +ORSP 1.1 does not define authentication or user profiles. + +## 8. Categories (optional) + +Sources declaring `categories` MUST implement: + +```text +GET v1/categories +``` + +Response: + +```json +{ + "items": [ + {"id": "fiction", "name": "Fiction"}, + {"id": "history", "name": "History"} + ] +} +``` + +`items` is required. Category IDs follow the opaque wire-ID rules in Section +4.2 and MUST be accepted unchanged as the `category` browse parameter. +Category names are human-readable. Sources SHOULD keep IDs stable when names +change. Category ordering is source-defined and SHOULD be deterministic. + +## 9. Browse (optional) + +Sources declaring `browse` MUST implement: + +```text +GET v1/browse?category={categoryId}&sort={sort}&page={page}&pageSize={pageSize} +``` + +`category` is optional. When present it MUST contain between 1 and 200 +characters and identify a category returned by `v1/categories`. A source that +does not declare `categories` MUST return `400` if `category` is supplied. + +`sort` is optional and defaults to `latest`. ORSP 1.1 defines: + +- `latest`: descending by the book's `updatedAt`, with books lacking + `updatedAt` ordered after dated books; +- `popular`: source-defined popularity ranking, deterministic for an unchanged + ranking snapshot. + +Sources MUST return `400` for an unsupported sort value. `page` and `pageSize` +use the search pagination rules. The response uses the search-page schema. +When `category` is omitted, the endpoint browses all books visible to the +source under the selected sort. + +## 10. Book details Endpoint relation: @@ -227,7 +331,7 @@ The response uses the same book object as search and MAY provide a longer description or additional optional metadata. Existing member meanings MUST be identical in search and detail responses. -## 8. Chapter catalog +## 11. Chapter catalog Endpoint relation: @@ -263,7 +367,7 @@ Catalog pages MUST be sorted by `(order, id)`. A source SHOULD return an `ETag` for each catalog page. A client that observes a changed catalog validator while loading multiple pages SHOULD restart catalog loading to avoid a mixed view. -## 9. Chapter content +## 12. Chapter content Endpoint relation: @@ -285,7 +389,7 @@ GET v1/books/{bookId}/chapters/{chapterId} `bookId` and `chapterId` MUST exactly match the requested resource. `baseUrl` is optional and, when present, MUST be an absolute HTTP(S) URL. -ORSP 1.0 content types are: +ORSP 1.1 content types are: - `text/plain`; - `text/markdown`, interpreted as CommonMark; @@ -299,7 +403,7 @@ remove event-handler attributes, dangerous URL schemes, embedded frames, and active form controls. Clients MUST still sanitize HTML before rendering it. Raw HTML embedded in Markdown MUST receive the same treatment. -## 10. Errors +## 13. Errors Core status codes: @@ -334,7 +438,7 @@ optional. Clients MUST ignore unknown error members and unknown error codes. Sources MUST NOT include credentials, cookies, internal URLs, stack traces, or other sensitive implementation details in error responses. -## 11. Security and privacy +## 14. Security and privacy Sources MUST validate known query parameters and IDs and SHOULD impose rate, response-size, execution-time, and upstream-fetch limits. Production sources @@ -352,21 +456,27 @@ required for native clients. ORSP does not grant copyright or access rights. Source operators are responsible for distributing only content they are authorized to provide. -## 12. Conformance +## 15. Conformance -An implementation claiming `ORSP 1.0 Core Reading` conformance MUST: +An implementation claiming `ORSP 1.1 Core Reading` conformance MUST: 1. implement discovery and all four core capabilities; 2. satisfy the normative requirements in this document; 3. pass the official core conformance suite for the claimed version; 4. disclose optional extension capabilities separately. +An implementation claiming `ORSP 1.1 Discovery` conformance MUST additionally +implement and advertise `discover`, `categories`, and `browse`, and pass the +official discovery-profile checks. A source MAY implement only a subset of the +optional standard capabilities without claiming the complete Discovery +profile. + Passing syntax validation alone is not evidence of protocol conformance. Conformance reports SHOULD record the test-suite version and execution date. -## 13. Reserved future work +## 16. Reserved future work -The following features are intentionally outside ORSP 1.0 and require a future +The following features are intentionally outside ORSP 1.1 and require a future minor version, major version, or companion specification: - authentication and paid-content authorization; diff --git a/examples/dart_server.dart b/examples/dart_server.dart index 091b825..b765ac6 100644 --- a/examples/dart_server.dart +++ b/examples/dart_server.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -const protocolVersion = '1.0'; +const protocolVersion = '1.1'; final _opaqueIdPattern = RegExp(r'^[A-Za-z0-9._~-]{1,200}$'); class ReferenceSourceServer { @@ -66,10 +66,30 @@ class ReferenceSourceServer { 'description': 'Original sample content for protocol development.', 'apiBaseUrl': baseUri.resolve('api/').toString(), 'languages': ['zh-CN', 'en'], - 'capabilities': ['search', 'detail', 'catalog', 'content'], + 'capabilities': [ + 'search', + 'discover', + 'categories', + 'browse', + 'detail', + 'catalog', + 'content', + ], }); return; } + if (path == '/api/v1/discover') { + _discover(request); + return; + } + if (path == '/api/v1/categories') { + _categories(request); + return; + } + if (path == '/api/v1/browse') { + _browse(request); + return; + } if (path == '/api/v1/search') { _search(request); return; @@ -156,6 +176,75 @@ class ReferenceSourceServer { }); } + void _discover(HttpRequest request) { + final items = books.values.map((book) => book.metadata).toList(); + final latest = [...items] + ..sort((a, b) => '${b['updatedAt']}'.compareTo('${a['updatedAt']}')); + _sendJson(request, 200, { + 'sections': [ + {'id': 'featured', 'title': 'Featured', 'items': items}, + {'id': 'latest', 'title': 'Latest updates', 'items': latest}, + ], + }); + } + + void _categories(HttpRequest request) { + _sendJson(request, 200, { + 'items': categoryDefinitions.entries + .map((entry) => {'id': entry.key, 'name': entry.value}) + .toList(), + }); + } + + void _browse(HttpRequest request) { + final category = request.uri.queryParameters['category']; + if (category != null && + (!_validId(category) || !categoryDefinitions.containsKey(category))) { + _error(request, 400, 'INVALID_CATEGORY'); + return; + } + final sort = request.uri.queryParameters['sort'] ?? 'latest'; + if (sort != 'latest' && sort != 'popular') { + _error(request, 400, 'INVALID_SORT'); + return; + } + final pagination = _pagination(request); + if (pagination == null) return; + + final matches = books.values.where((book) { + if (category == null) return true; + final categoryName = categoryDefinitions[category]; + final values = book.metadata['categories']; + return values is List && values.contains(categoryName); + }).toList(); + matches.sort((a, b) { + if (sort == 'popular') { + final left = bookPopularity[a.metadata['id']] ?? 0; + final right = bookPopularity[b.metadata['id']] ?? 0; + final byPopularity = right.compareTo(left); + if (byPopularity != 0) return byPopularity; + } else { + final byUpdated = '${b.metadata['updatedAt']}'.compareTo( + '${a.metadata['updatedAt']}', + ); + if (byUpdated != 0) return byUpdated; + } + return '${a.metadata['id']}'.compareTo('${b.metadata['id']}'); + }); + + final items = _page( + matches, + pagination, + ).map((book) => book.metadata).toList(growable: false); + _sendJson(request, 200, { + 'items': items, + 'page': pagination.page, + 'pageSize': pagination.pageSize, + 'total': matches.length, + 'hasMore': pagination.end < matches.length, + }); + } + void _catalog(HttpRequest request, BookData book) { final pagination = _pagination(request, defaultPageSize: 100); if (pagination == null) return; @@ -341,6 +430,16 @@ const books = { ), }; +const categoryDefinitions = { + 'original': 'Original', + 'protocol': 'Protocol', +}; + +const bookPopularity = { + 'protocol-garden': 100, + 'river-of-pages': 80, +}; + Future main(List arguments) async { final portIndex = arguments.indexOf('--port'); final port = portIndex >= 0 && portIndex + 1 < arguments.length diff --git a/examples/open-reading-source.json b/examples/open-reading-source.json index 58496de..c825c08 100644 --- a/examples/open-reading-source.json +++ b/examples/open-reading-source.json @@ -1,6 +1,6 @@ { "protocol": "open-reading-source", - "protocolVersion": "1.0", + "protocolVersion": "1.1", "id": "org.example.public-books", "name": "Example Public Books", "description": "A minimal Open Reading compatible source", @@ -8,5 +8,13 @@ "iconUrl": "https://books.example.org/icon.png", "websiteUrl": "https://books.example.org/", "languages": ["zh-CN", "en"], - "capabilities": ["search", "detail", "catalog", "content"] + "capabilities": [ + "search", + "discover", + "categories", + "browse", + "detail", + "catalog", + "content" + ] } diff --git a/openapi.yaml b/openapi.yaml index 6217727..3e67562 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,14 +1,82 @@ openapi: 3.1.0 info: title: Open Reading Source Protocol - version: '1.0' - description: Machine-readable contract for the ORSP 1.0 Core Reading profile. + version: '1.1' + description: Machine-readable contract for ORSP 1.1 Core Reading and optional Discovery profiles. license: name: MIT identifier: MIT servers: - url: https://books.example.org/api paths: + /v1/discover: + get: + operationId: discoverBooks + summary: Get curated discovery shelves + parameters: + - $ref: '#/components/parameters/ProtocolVersion' + responses: + '200': + description: Curated discovery shelves + headers: + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + schema: { $ref: '#/components/schemas/DiscoveryPage' } + '304': { $ref: '#/components/responses/NotModified' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '429': { $ref: '#/components/responses/RateLimited' } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/Unavailable' } + /v1/categories: + get: + operationId: listCategories + summary: List browseable categories + parameters: + - $ref: '#/components/parameters/ProtocolVersion' + responses: + '200': + description: Source-defined categories + headers: + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + schema: { $ref: '#/components/schemas/CategoryPage' } + '304': { $ref: '#/components/responses/NotModified' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '429': { $ref: '#/components/responses/RateLimited' } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/Unavailable' } + /v1/browse: + get: + operationId: browseBooks + summary: Browse books by category or standard sort + parameters: + - $ref: '#/components/parameters/ProtocolVersion' + - name: category + in: query + required: false + schema: { $ref: '#/components/schemas/OpaqueId' } + - name: sort + in: query + required: false + schema: { type: string, enum: [latest, popular], default: latest } + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + responses: + '200': + description: Browse results + headers: + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + schema: { $ref: '#/components/schemas/SearchPage' } + '304': { $ref: '#/components/responses/NotModified' } + '400': { $ref: '#/components/responses/BadRequest' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '429': { $ref: '#/components/responses/RateLimited' } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/Unavailable' } /v1/search: get: operationId: searchBooks @@ -226,6 +294,40 @@ components: pageSize: { type: integer, minimum: 1, maximum: 100 } total: { type: integer, minimum: 0 } hasMore: { type: boolean } + DiscoveryPage: + type: object + additionalProperties: true + required: [sections] + properties: + sections: + type: array + items: { $ref: '#/components/schemas/DiscoverySection' } + DiscoverySection: + type: object + additionalProperties: true + required: [id, title, items] + properties: + id: { $ref: '#/components/schemas/OpaqueId' } + title: { type: string, minLength: 1, maxLength: 200 } + items: + type: array + maxItems: 100 + items: { $ref: '#/components/schemas/Book' } + CategoryPage: + type: object + additionalProperties: true + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Category' } + Category: + type: object + additionalProperties: true + required: [id, name] + properties: + id: { $ref: '#/components/schemas/OpaqueId' } + name: { type: string, minLength: 1, maxLength: 200 } Chapter: type: object additionalProperties: true diff --git a/schemas/discovery.schema.json b/schemas/discovery.schema.json index e0a2245..1c66f67 100644 --- a/schemas/discovery.schema.json +++ b/schemas/discovery.schema.json @@ -75,7 +75,15 @@ "items": { "anyOf": [ { - "enum": ["search", "detail", "catalog", "content"] + "enum": [ + "search", + "discover", + "categories", + "browse", + "detail", + "catalog", + "content" + ] }, { "type": "string", @@ -84,5 +92,20 @@ ] } } - } + }, + "allOf": [ + { + "if": { + "properties": { + "capabilities": { "contains": { "const": "categories" } } + }, + "required": ["capabilities"] + }, + "then": { + "properties": { + "capabilities": { "contains": { "const": "browse" } } + } + } + } + ] } diff --git a/scripts/registry_tool.py b/scripts/registry_tool.py index 517cc68..de1cbd2 100644 --- a/scripts/registry_tool.py +++ b/scripts/registry_tool.py @@ -267,7 +267,7 @@ def fetch(url: str) -> HttpResult: "Accept": "application/json", "Accept-Encoding": "identity", "User-Agent": "ORSP-Registry-Validator/1.0", - "X-Open-Reading-Protocol": "1.0", + "X-Open-Reading-Protocol": "1.1", }, ) response = connection.getresponse() @@ -414,7 +414,7 @@ def check_source(path: Path) -> dict[str, Any]: if content.get("bookId") != book_id or content.get("chapterId") != chapter_id: raise RegistryError("Chapter content IDs do not match the requested resource") if content.get("contentType") not in {"text/plain", "text/markdown", "text/html"}: - raise RegistryError("Chapter contentType is not supported by ORSP 1.0") + raise RegistryError("Chapter contentType is not supported by ORSP 1.1") if not isinstance(content.get("content"), str): raise RegistryError("Chapter content must be a string") diff --git a/scripts/validate_contracts.py b/scripts/validate_contracts.py index 4e50d62..7fde2ed 100644 --- a/scripts/validate_contracts.py +++ b/scripts/validate_contracts.py @@ -41,6 +41,14 @@ def main() -> None: missing_core["capabilities"].remove("content") expect_invalid(validator, missing_core, "missing core capability") + categories_without_browse = copy.deepcopy(discovery) + categories_without_browse["capabilities"].remove("browse") + expect_invalid( + validator, + categories_without_browse, + "categories capability without browse", + ) + no_trailing_slash = copy.deepcopy(discovery) no_trailing_slash["apiBaseUrl"] = "https://books.example.org/api" expect_invalid(validator, no_trailing_slash, "API URL without trailing slash") diff --git a/tool/conformance_test.dart b/tool/conformance_test.dart index 8bc7d35..98947cd 100644 --- a/tool/conformance_test.dart +++ b/tool/conformance_test.dart @@ -28,24 +28,74 @@ Future main() async { 'discovery uses application/json', ); check( - discovery.headers.value('x-open-reading-protocol') == '1.0', + discovery.headers.value('x-open-reading-protocol') == '1.1', 'source advertises its protocol version header', ); final discoveryJson = discovery.json as Map; check(discoveryJson['protocol'] == 'open-reading-source', 'protocol token'); - check(discoveryJson['protocolVersion'] == '1.0', 'protocol version'); + check(discoveryJson['protocolVersion'] == '1.1', 'protocol version'); final capabilities = (discoveryJson['capabilities'] as List).toSet(); check( - capabilities.containsAll(['search', 'detail', 'catalog', 'content']), - 'all core capabilities are advertised', + capabilities.containsAll([ + 'search', + 'discover', + 'categories', + 'browse', + 'detail', + 'catalog', + 'content', + ]), + 'core and discovery capabilities are advertised', ); final apiBase = Uri.parse(discoveryJson['apiBaseUrl'] as String); check(apiBase.path.endsWith('/'), 'apiBaseUrl ends in a slash'); + final discover = await request(client, apiBase.resolve('v1/discover')); + check(discover.status == 200, 'discover returns 200'); + final discoverSections = (discover.json as Map)['sections'] as List; + check(discoverSections.length == 2, 'discover returns curated sections'); + check( + (discoverSections.first as Map)['id'] == 'featured', + 'discover section IDs are stable', + ); + + final categories = await request(client, apiBase.resolve('v1/categories')); + check(categories.status == 200, 'categories returns 200'); + final categoryItems = (categories.json as Map)['items'] as List; + check(categoryItems.length == 2, 'categories returns source categories'); + check( + categoryItems.any((item) => (item as Map)['id'] == 'protocol'), + 'category IDs are browseable', + ); + + final latest = await request( + client, + apiBase.resolve('v1/browse?sort=latest&page=1&pageSize=20'), + headers: {'X-Open-Reading-Protocol': '1.1'}, + ); + check(latest.status == 200, 'latest browse returns 200'); + final latestItems = (latest.json as Map)['items'] as List; + check( + (latestItems.first as Map)['id'] == 'river-of-pages', + 'latest browse ordering is deterministic', + ); + + final categoryBrowse = await request( + client, + apiBase.resolve('v1/browse?category=protocol&sort=popular'), + ); + final categoryBrowseItems = (categoryBrowse.json as Map)['items'] as List; + check(categoryBrowse.status == 200, 'category browse returns 200'); + check(categoryBrowseItems.length == 1, 'category browse filters books'); + check( + (categoryBrowseItems.single as Map)['id'] == 'protocol-garden', + 'category browse uses category IDs unchanged', + ); + final searchPage1 = await request( client, apiBase.resolve('v1/search?q=Open%20Reading&page=1&pageSize=1&future=x'), - headers: {'X-Open-Reading-Protocol': '1.0'}, + headers: {'X-Open-Reading-Protocol': '1.1'}, ); check(searchPage1.status == 200, 'search returns 200'); final searchJson1 = searchPage1.json as Map; @@ -119,6 +169,12 @@ Future main() async { ); checkError(invalidPage, 400, 'INVALID_PAGE', check); + final invalidSort = await request( + client, + apiBase.resolve('v1/browse?sort=random'), + ); + checkError(invalidSort, 400, 'INVALID_SORT', check); + final missingBook = await request( client, apiBase.resolve('v1/books/does-not-exist'),