Updated the API documentation for the new tooling.

- split the massive bookmarks/routes.yaml into several files
- moved big markdown contents to individual files
- moved the OAuth documentation to the API description
- fixed a few minor bugs with schemas
- added a "data" definition to file-based import routes
This commit is contained in:
Olivier Meunier
2025-10-27 16:57:45 +01:00
parent cd43ed716a
commit df3a0d6318
14 changed files with 771 additions and 717 deletions

View File

@@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
allow-server-selection="false"
allow-spec-file-download="true"
default-schema-tab="schema"
info-description-headings-in-navbar="true"
load-fonts="false"
nav-bg-color="#ffffff"
persist-auth="true"

View File

@@ -7,66 +7,10 @@ openapi: 3.0.0
info:
version: 1.0.0
title: Readeck API
description: |
# Introduction
The Readeck API provides REST endpoints that can be used for any purpose, should it be a
mobile application, a script, you name it.
## API Endpoint
You can access this API on `__BASE_URI__`.
Most of your requests and responses are using JSON as the exchange format.
## Test the API
On this documentation, you can test every route.
If you don't provide an API token in [Authentication](#auth), you can still test all the routes
but note that the given curl examples only work with an API token.
## Authentication
### Create your own token
If you're writing a script for yourself, the easiest way is to
[generate an API token](../profile/tokens) that you can use using
the `Bearer` HTTP authorization scheme.
For example, you first request will look like:
```sh
curl -H "Authorization: Bearer <TOKEN>" __BASE_URI__/bookmarks
```
Or, in NodeJS:
```js
fetch("__BASE_URI__/bookmarks", {
headers: {
"Authorization": "Bearer <TOKEN>",
},
})
```
### OAuth
If you're writing an application, Readeck provides the OAuth 2 authorization code flow.
#### Client Registration
Before you can send your app's user to the authorization URL, you first need to register a
client on the Readeck instance.
Please refere to the [Client Creation Route](#post-/oauth/client) for more information.
#### Authorization flow
Once you received a `client_id`, you can build an authorization URL as explained in the
[OAuth Token route](#post-/oauth/token). The rest of the process is following the OAuth 2
specifications.
description:
$include:
- base.md
- oauth.md
servers:
@@ -93,20 +37,28 @@ tags:
- name: user profile
- name: bookmarks
- name: bookmark export
- name: bookmark sync
- name: bookmark sharing
- name: bookmark labels
- name: bookmark highlights
- name: bookmark collections
- name: bookmark sync
x-tag-expanded: false
- name: bookmark import
x-tag-expanded: false
- name: dev tools
- name: oauth client
x-tag-expanded: false
- name: oauth authorization
x-tag-expanded: false
- name: oauth client
x-tag-expanded: false
paths:
$merge:
- "info/routes.yaml#.routes"
- "oauth/routes.yaml#.routes"
- "profile/routes.yaml#.routes"
- "bookmarks/routes.yaml#.routes"
- "bookmarks/routes-bookmarks.yaml#.routes"
- "bookmarks/routes-collections.yaml#.routes"
- "bookmarks/routes-import.yaml#.routes"
- "bookmarks/routes-sync.yaml#.routes"
- "cookbook/routes.yaml#.routes"

40
docs/api/base.md Normal file
View File

@@ -0,0 +1,40 @@
<!--
SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
SPDX-License-Identifier: AGPL-3.0-only
-->
# Introduction
The Readeck API provides REST endpoints that can be used for any purpose, should it be a mobile application, a script, you name it.
## API Endpoint
You can access this API on `__BASE_URI__`.
Most of your requests and responses are using JSON as the exchange format.
## Test the API
On this documentation, you can test every route.
If you don't provide an API token in [Authentication](#auth), you can still test all the routes but note that the given curl examples only work with an API token.
# Token Authentication
If you're writing a script for yourself, the easiest way is to [generate an API token](../profile/tokens) that you can use using the `Bearer` HTTP authorization scheme.
For example, you first request will look like:
```sh
curl -H "Authorization: Bearer <TOKEN>" __BASE_URI__/profile
```
Or, in NodeJS:
```js
fetch("__BASE_URI__/profile", {
headers: {
"Authorization": "Bearer <TOKEN>",
},
})
```

View File

@@ -0,0 +1,84 @@
<!--
SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
SPDX-License-Identifier: AGPL-3.0-only
-->
This route returns a `multipart/mixed` response with all the bookmarks passed in `id` (or all of them if unset).
Here is an example:
```
--910345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b
Bookmark-Id: VnopmpKQ3CmQ6apY9mgDws
Content-Disposition: attachment; filename="info.json"
Content-Type: application/json; charset=utf-8
Date: 2025-06-20T10:53:47Z
Filename: info.json
Last-Modified: 2025-07-03T12:49:30Z
Location: http://localhost:8000/api/bookmarks/VnopmpKQ3CmQ6apY9mgDws
Type: json
...content
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b
Bookmark-Id: VnopmpKQ3CmQ6apY9mgDws
Content-Disposition: attachment; filename="index.html"
Content-Type: text/html; charset=utf-8
Date: 2025-06-20T10:53:47Z
Filename: index.html
Last-Modified: 2025-07-03T12:49:30Z
Type: html
...content
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b
Bookmark-Id: VnopmpKQ3CmQ6apY9mgDws
Content-Disposition: attachment; filename="image.jpeg"
Content-Length: 86745
Content-Type: image/jpeg
Filename: image.jpeg
Group: image
Location: http://localhost:8000/bm/Vn/VnopmpKQ3CmQ6apY9mgDws/img/image.jpeg
Path: image.jpeg
Type: resource
...content
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b
Bookmark-Id: VnopmpKQ3CmQ6apY9mgDws
Content-Disposition: attachment; filename="Wj66qLatSeikPc31FwvqyS.jpg"
Content-Length: 171749
Content-Type: image/jpeg
Filename: Wj66qLatSeikPc31FwvqyS.jpg
Group: embedded
Location: http://localhost:8000/bm/Vn/VnopmpKQ3CmQ6apY9mgDws/_resources/Wj66qLatSeikPc31FwvqyS.jpg
Path: Wj66qLatSeikPc31FwvqyS.jpg
Type: resource
...content
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b--
```
For each bookmark, there will be entries with a `Type` header:
- a `Type: json` entry, controlled by `with_json`. It contains the same output as an API bookmark information.
- a `Type: html` entry, controlled by `with_html`. It contains the HTML content (article), if any.
- a `Type: markdown` entry, controlled by `with_markdown`. It contains the bookmark converted to Markdown.
- several `Type: resource` entries, controlled by `with_resources`. Each entry is a resource (icon, images, article images).
Each entry has a `Bookmark-Id` attribute that indicates the bookmark it belongs to.
Each `Type: resource` entry contains a `Path` header that's based on the `resource_prefix`
parameter.
Each `Type: resource` entry contains a `Group` header that can take the following values:
- `icon`: the bookmark's icon,
- `image`: the bookmark's image (main picture for photo types, and placeholder for videos),
- `thumbnail`: thumbnail of the image,
- `embedded`: included in the article itself.
The response's content is a stream and should be processed while the data is coming, part by
part.

View File

@@ -1,5 +1,5 @@
---
# SPDX-FileCopyrightText: © 2023 Olivier Meunier <olivier@neokraft.net>
# SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
#
# SPDX-License-Identifier: AGPL-3.0-only
@@ -32,29 +32,11 @@ withAnnotation:
type: string
format: short-uid
withCollection:
parameters:
- name: id
in: path
required: true
description: Collection ID
schema:
type: string
format: short-uid
withMultipartImport:
requestBody:
content:
multipart/form-data:
schema:
allOf:
- $ref: "#components/schemas/baseImport"
routes:
/bookmarks:
get:
tags: [bookmarks]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.paginated"
@@ -151,7 +133,7 @@ routes:
type: string
responses:
'200':
200:
description: List of bookmark items
content:
application/json:
@@ -177,7 +159,7 @@ routes:
$ref: "#/components/schemas/bookmarkCreate"
responses:
"202":
202:
headers:
Bookmark-Id:
schema:
@@ -197,7 +179,7 @@ routes:
description: Retrieves a saved bookmark
responses:
"200":
200:
description: Bookmark details
content:
application/json:
@@ -222,7 +204,7 @@ routes:
$ref: "#/components/schemas/bookmarkUpdate"
responses:
"200":
200:
description: Bookmark updated
content:
application/json:
@@ -255,7 +237,7 @@ routes:
This route returns the bookmark's article if it exists.
responses:
"200":
200:
description: |
A `text/html` response, containing the article body.
Please note that it's only the fragment and not a full HTML document.
@@ -287,7 +269,7 @@ routes:
enum: [epub, md]
responses:
"200":
200:
content:
application/epub+zip:
schema:
@@ -310,7 +292,7 @@ routes:
description: This route produces a publicly accessible link to share a bookmark.
responses:
"200":
200:
description: Public link information
content:
application/json:
@@ -336,149 +318,13 @@ routes:
$ref: "#/components/schemas/bookmarkShareEmail"
responses:
"200":
200:
description: Message sent
content:
application/json:
schema:
$ref: "#/components/schemas/message"
/bookmarks/sync:
get:
tags: [bookmark sync]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
summary: Bookmark Sync List
description: |
This route returns a non-paginated list of all bookmarks ordered by last update dates.
You can retrieve only bookmarks updated and deleted since a given date by using the
`since` parameter. Please note that without the parameter, it only returns updated
bookmarks.
parameters:
- name: since
in: query
description: A datetime to retrieve updated and deleted IDs including and after this value.
schema:
type: string
format: date-time
responses:
'200':
description: Item list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/bookmarkSyncList"
post:
tags: [bookmark sync]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
summary: Bookmark Sync
description: |
This then returns a `multipart/mixed` response with all the bookmarks passed in `id` (or all of them if unset).
Here is an example:
```
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b
Bookmark-Id: VnopmpKQ3CmQ6apY9mgDws
Content-Disposition: attachment; filename="info.json"
Content-Type: application/json; charset=utf-8
Date: 2025-06-20T10:53:47Z
Filename: info.json
Last-Modified: 2025-07-03T12:49:30Z
Location: http://localhost:8000/api/bookmarks/VnopmpKQ3CmQ6apY9mgDws
Type: json
...content
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b
Bookmark-Id: VnopmpKQ3CmQ6apY9mgDws
Content-Disposition: attachment; filename="index.html"
Content-Type: text/html; charset=utf-8
Date: 2025-06-20T10:53:47Z
Filename: index.html
Last-Modified: 2025-07-03T12:49:30Z
Type: html
...content
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b
Bookmark-Id: VnopmpKQ3CmQ6apY9mgDws
Content-Disposition: attachment; filename="image.jpeg"
Content-Length: 86745
Content-Type: image/jpeg
Filename: image.jpeg
Group: image
Location: http://localhost:8000/bm/Vn/VnopmpKQ3CmQ6apY9mgDws/img/image.jpeg
Path: image.jpeg
Type: resource
...content
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b
Bookmark-Id: VnopmpKQ3CmQ6apY9mgDws
Content-Disposition: attachment; filename="Wj66qLatSeikPc31FwvqyS.jpg"
Content-Length: 171749
Content-Type: image/jpeg
Filename: Wj66qLatSeikPc31FwvqyS.jpg
Group: embedded
Location: http://localhost:8000/bm/Vn/VnopmpKQ3CmQ6apY9mgDws/_resources/Wj66qLatSeikPc31FwvqyS.jpg
Path: Wj66qLatSeikPc31FwvqyS.jpg
Type: resource
...content
--965dd10345ce0f660bda92b9e8a1192532f999a51151dccb7227d784049b--
```
For each bookmark, there will be entries with a `Type` header:
- a `Type: json` entry, controlled by `with_json`. It contains the same output as an API bookmark information.
- a `Type: html` entry, controlled by `with_html`. It contains the HTML content (article), if any.
- a `Type: markdown` entry, controlled by `with_markdown`. It contains the bookmark converted to Markdown.
- several `Type: resource` entries, controlled by `with_resources`. Each entry is a resource (icon, images, article images).
Each entry has a `Bookmark-Id` attribute that indicates the bookmark it belongs to.
Each `Type: resource` entry contains a `Path` header that's based on the `resource_prefix`
parameter.
Each `Type: resource` entry contains a `Group` header that can take the following values:
- `icon`: the bookmark's icon,
- `image`: the bookmark's image (main picture for photo types, and placeholder for videos),
- `thumbnail`: thumbnail of the image,
- `embedded`: included in the article itself.
The response's content is a stream and should be processed while the data is coming, part by
part.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/bookmarkSyncParams"
responses:
'200':
description: Item list
content:
multipart/mixed:
schema:
type: string
format: binary
/bookmarks/labels:
get:
tags: [bookmark labels]
@@ -490,7 +336,7 @@ routes:
This route returns all the labels associated to a bookmark for the current user.
responses:
"200":
200:
description: Label list
content:
application/json:
@@ -513,7 +359,7 @@ routes:
This route returns information about a given bookmark label.
responses:
"200":
200:
description: Label information
content:
application/json:
@@ -539,7 +385,7 @@ routes:
$ref: "#/components/schemas/labelUpdate"
responses:
"200":
200:
description: Label renamed
delete:
@@ -554,7 +400,7 @@ routes:
Please note that it does not remove the bookmarks themselves.
responses:
"204":
204:
description: Label removed
/bookmarks/annotations:
@@ -626,7 +472,7 @@ routes:
$ref: "#/components/schemas/annotationCreate"
responses:
"201":
201:
description: Highlight created
content:
application/json:
@@ -680,235 +526,4 @@ routes:
responses:
"204":
description: Highlight removed
/bookmarks/collections:
get:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.paginated"
summary: Collection List
description: |
This route returns all the current user's collections.
responses:
"200":
description: Collection list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/collectionInfo"
post:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.created"
summary: Collection Create
description: |
This route creates a new collection.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/collectionCreate"
/bookmarks/collections/{id}:
$merge:
- "#.withCollection"
get:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
summary: Collection Details
description: |
This route returns a given collection information.
responses:
"200":
description: Collection information
content:
application/json:
schema:
$ref: "#/components/schemas/collectionInfo"
patch:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
summary: Collection Update
description: |
This route updates a given collection. It returns a mapping of updated fields.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/collectionUpdate"
responses:
"200":
description: Updated fields
content:
application/json:
schema:
$ref: "#/components/schemas/collectionSummary"
delete:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
summary: Collection Delete
description: |
This route deletes a given collection.
responses:
"204":
description: Collection deleted
/bookmarks/import/browser:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import Browser Bookmarks
description: |
This route creates bookmarks from an HTML file generated by an export of a browser's
bookmarks.
/bookmarks/import/csv:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import CSV files
description: |
This route creates bookmarks from a CSV file. Compatible with Instapaper.
The uploaded file must contain a first row with the column names. Column names are case insensitive and, except for url, every column is optional.
Here are the columns you can set:
| Field | Alias | Description
| ------------------------ | :------------- | :----------
| `url` (**required**) | | Link address
| `title` | | Bookmark title
| `state` | `folder` | Bookmark's archived state; only valid value is "archive"
| `created` | `timestamp` | Creation date, can be a UNIX timestamp or an RFC-3339 formatted date
| `labels` | `tags` | A JSON encoded list of labels
Example:
```
url,title,state,created,labels
https://www.the-reframe.com/all-in-the-same-boat/,"All In The Same Boat",,2025-01-12T10:45:56,"[""label 1"",""label 2""]"
```
/bookmarks/import/goodlinks:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import GoodLinks files.
description: |
This route creates bookmarks from a GoodLinks export file.
/bookmarks/import/linkwarden:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import Linkwarden files.
description: |
This route creates bookmarks from a Linkwarden export file.
/bookmarks/import/pocket-file:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import Pocket Saves
description: |
This route creates bookmarks from an HTML file generated by Pocket export tool.
Go to [https://getpocket.com/export](https://getpocket.com/export) to generate
such a file.
/bookmarks/import/readwise:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import Readwise files.
description: |
This route creates bookmarks from a Readwise export file (CSV).
/bookmarks/import/text:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import a Text File
description: |
This route creates bookmarks from a text file that contains one URL
per line.
/bookmarks/import/wallabag:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
summary: Import Wallabag Articles
description: |
This route imports articles from Wallabag using its API.
You must create an API client in Wallabag and use its "Client ID" and "Client Secret"
in this route's payload.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/wallabagImport"
description: Highlight removed

View File

@@ -0,0 +1,111 @@
---
# SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
#
# SPDX-License-Identifier: AGPL-3.0-only
withCollection:
parameters:
- name: id
in: path
required: true
description: Collection ID
schema:
type: string
format: short-uid
routes:
/bookmarks/collections:
get:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.paginated"
summary: Collection List
description: |
This route returns all the current user's collections.
responses:
"200":
description: Collection list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/collectionInfo"
post:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.created"
summary: Collection Create
description: |
This route creates a new collection.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/collectionCreate"
/bookmarks/collections/{id}:
$merge:
- "#.withCollection"
get:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
summary: Collection Details
description: |
This route returns a given collection information.
responses:
"200":
description: Collection information
content:
application/json:
schema:
$ref: "#/components/schemas/collectionInfo"
patch:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
summary: Collection Update
description: |
This route updates a given collection. It returns a mapping of updated fields.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/collectionUpdate"
responses:
"200":
description: Updated fields
content:
application/json:
schema:
$ref: "#/components/schemas/collectionSummary"
delete:
tags: [bookmark collections]
$merge:
- "traits.yaml#.authenticated"
summary: Collection Delete
description: |
This route deletes a given collection.
responses:
"204":
description: Collection deleted

View File

@@ -0,0 +1,154 @@
---
# SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
#
# SPDX-License-Identifier: AGPL-3.0-only
withMultipartImport:
requestBody:
content:
multipart/form-data:
schema:
allOf:
- $ref: "#components/schemas/baseImport"
- properties:
data:
type: string
format: binary
description: Import file
routes:
/bookmarks/import/browser:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import Browser Bookmarks
description: |
This route creates bookmarks from an HTML file generated by an export of a browser's
bookmarks.
/bookmarks/import/csv:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import CSV files
description: |
This route creates bookmarks from a CSV file. Compatible with Instapaper.
The uploaded file must contain a first row with the column names. Column names are case insensitive and, except for url, every column is optional.
Here are the columns you can set:
| Field | Alias | Description
| ------------------------ | :------------- | :----------
| `url` (**required**) | | Link address
| `title` | | Bookmark title
| `state` | `folder` | Bookmark's archived state; only valid value is "archive"
| `created` | `timestamp` | Creation date, can be a UNIX timestamp or an RFC-3339 formatted date
| `labels` | `tags` | A JSON encoded list of labels
Example:
```
url,title,state,created,labels
https://www.the-reframe.com/all-in-the-same-boat/,"All In The Same Boat",,2025-01-12T10:45:56,"[""label 1"",""label 2""]"
```
/bookmarks/import/goodlinks:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import GoodLinks files.
description: |
This route creates bookmarks from a GoodLinks export file.
/bookmarks/import/linkwarden:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import Linkwarden files.
description: |
This route creates bookmarks from a Linkwarden export file.
/bookmarks/import/pocket-file:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import Pocket Saves
description: |
This route creates bookmarks from an HTML file generated by Pocket export tool.
Go to [https://getpocket.com/export](https://getpocket.com/export) to generate
such a file.
/bookmarks/import/readwise:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import Readwise files.
description: |
This route creates bookmarks from a Readwise export file (CSV).
/bookmarks/import/text:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
- "#.withMultipartImport"
summary: Import a Text File
description: |
This route creates bookmarks from a text file that contains one URL
per line.
/bookmarks/import/wallabag:
post:
tags: [bookmark import]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
- "traits.yaml#.deferred"
summary: Import Wallabag Articles
description: |
This route imports articles from Wallabag using its API.
You must create an API client in Wallabag and use its "Client ID" and "Client Secret"
in this route's payload.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/wallabagImport"

View File

@@ -0,0 +1,63 @@
---
# SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
#
# SPDX-License-Identifier: AGPL-3.0-only
routes:
/bookmarks/sync:
get:
tags: [bookmark sync]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
summary: Bookmark Sync List
description: |
This route returns a non-paginated list of all bookmarks ordered by last update dates.
You can retrieve only bookmarks updated and deleted since a given date by using the
`since` parameter. Please note that without the parameter, it only returns updated
bookmarks.
parameters:
- name: since
in: query
description: A datetime to retrieve updated and deleted IDs including and after this value.
schema:
type: string
format: date-time
responses:
'200':
description: Item list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/bookmarkSyncList"
post:
tags: [bookmark sync]
$merge:
- "traits.yaml#.authenticated"
- "traits.yaml#.validator"
summary: Bookmark Sync
description:
$include: "bookmarks/doc-sync.md"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/bookmarkSyncParams"
responses:
'200':
description: Item list
content:
multipart/mixed:
schema:
type: string
format: binary

View File

@@ -142,12 +142,11 @@ routes:
get:
tags: [dev tools]
$merge:
- "traits.yaml#.admin-only"
- "traits.yaml#.authenticated"
summary: Extract Link
description: |
**NOTE: Only available to users in the admin group.**
This route extracts a link and returns the extraction result.
You can pass an `Accept` header to the request, with one of the following values:
@@ -163,12 +162,11 @@ routes:
post:
tags: [dev tools]
$merge:
- "traits.yaml#.admin-only"
- "traits.yaml#.authenticated"
summary: Extract Link with content
description: |
**NOTE: Only available to users in the admin group.**
This route extracts a link and returns the extraction result.
You can pass an `Accept` header to the request, with one of the following values:
@@ -205,12 +203,11 @@ routes:
get:
tags: [dev tools]
$merge:
- "traits.yaml#.admin-only"
- "traits.yaml#.authenticated"
summary: List test URLs
description: |
**NOTE: Only available for users in the admin group.**
Lists test URLs gathered from `pkg/extract/contentscripts/assets/site-config`
responses:

View File

@@ -9,6 +9,9 @@ routes:
get:
tags: [info]
security: []
x-badges:
- color: green
label: public
summary: Information
description: |

268
docs/api/oauth.md Normal file
View File

@@ -0,0 +1,268 @@
<!--
SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
SPDX-License-Identifier: AGPL-3.0-only
-->
# Authentication with OAuth
If you're writing an application that requires a user to grant the application permission to access its Readeck instance, you should not ask a user to create an API Token but, instead, implement the necessary OAuth flow so your application can retrieve a token in a user friendly way.
## Client Registration
Before you can start the authorization flow, you first need to register a client on the Readeck instance.
Readeck implement [OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591) and [OAuth 2.0 Dynamic Client Registration Management Protocol](https://datatracker.ietf.org/doc/html/rfc7592).
You can register a client by querying the [Client Creation Route](#post-/oauth/client).
Upon registration, you'll receive a `client_id` and a `registration_access_token`. You'll need them if you want to fetch, update or delete the client later. You must store this information as safely as a password.
Client registration flow:
```
+---------+ +---------------+
| Client | | Registration |
+---------+ +---------------+
| |
| Client Registration Request |
| POST /api/oauth/client |
|------------------------------------>|
| |
| Client Information Response |
|<------------------------------------|
| |
```
Once you have a client, you can retrieve its information, update it or delete it. See:
- [Client Info](#get-/oauth/client/-id-)
- [Client Update](#put-/oauth/client/-id-)
- [Client Delete](#delete-/oauth/client/-id-)
Client management flow:
```
+---------+ +---------------+
| Client | | Registration |
+---------+ +---------------+
| |
| Client Information Request |
| GET /api/oauth/client/{id} |
|------------------------------------>|
| |
| Client Information Response |
|<------------------------------------|
| |
| Read or Update Request |
| GET or PUT /api/oauth/client/{id} |
|------------------------------------>|
| |
| Client Information Response |
|<------------------------------------|
| |
| Delete Request |
| DELETE /api/oauth/client/{id} |
|------------------------------------>|
| |
| Delete Confirmation |
|<------------------------------------|
| |
```
Here's, in Javascript, an example of a client flow for an app:
```js
async function clientFlow() {
// Where you store the client_id and registration_access_token
const appVersion = "1.1.0"
const store = {}
let rsp
if (!store.clientID) {
// New client, create one
rsp = await fetch(
"__BASE_URI__/oauth/client",
{
"method": "POST",
"body": json.Stringify({
"client_name": "My new client",
"client_uri": "https://example.org/",
"redirect_uris": [
"https://example.org/callback"
],
"software_id": "some-uuid",
"software_version": "1.0.0",
})
},
)
let data = await rsp.json()
store.clientID = data["client_id"]
store.registrationAccessToken = data["registration_access_token"]
// we're done
return
}
// We have a client id, check if we need to update it
rsp = await fetch(
`__BASE_URI__/oauth/client/${store.clientID}`,
{
"headers": {"Authorization": `Bearer ${store.registrationAccessToken}`}
},
)
let data = await rsp.json()
if (data["software_version"] != appVersion) {
// We need to update the client
rsp = await fetch(
`__BASE_URI__/oauth/client/${store.clientID}`,
{
"method": "PUT",
"body": json.Stringify({
...data,
"software_version": appVersion,
}),
"headers": {"Authorization": `Bearer ${store.registrationAccessToken}`}
},
)
let data = await rsp.json()
}
}
```
## Available Scopes
An OAuth token grants the application some permissions based on the requested scopes. This are the available scopes you can request:
| Name | Description
| :---------------- | ------------
| `bookmarks:read` | Read only access to bookmarks
| `bookmarks:write` | Write only access to bookmarks
| `profile:read` | Extended profile information
## OAuth Authorization Code Flow
With the `client_id`, you can use the authorization code flow. You first need to build an authorization URL.
### Authorization
The authorization route is: `__ROOT_URI__/authorize` and it receives the following query parameters:
| Name | Description
| :---------------------- | :-----------------------------
| `client_id` | OAuth Client ID
| `redirect_uri` | Redirection URI (must match exactly one given during client registration)
| `scope` | Space separated list of scopes
| `code_challenge` | PKCE Challenge (mandatory)
| `code_challenge_method` | Only `S256` is allowed
| `state` | Optional client state
Sending a state is not mandatory but strongly advised to ensure that your side of the authorization flow has not been tampered.
### Authorization result
Once a user grants or denies an authorization request, it will be redirected to the `redirect_uri` with the following query parameters:
| Name | Description
| :------ | :--------------------------------------------------------------------
| `code` | The authorization code that the client must pass to the token request
| `state` | The state as initially set by the client
In case of error (request denied by the user or something else), the redirection contains
the following query parameters:
| Name | Description
| :------------------ | :--------------------------------------------------------------------
| `error` | Error code (can be `invalid_request` or `access_denied`)
| `error_description` | Error description
Once you receive a code, you can proceed to the [Token Request](#post-/oauth/token) to eventually receive an access token that will let you use the API.
### PKCE
The authorization code flow requires that you use [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) with an S256 method only (the "plain" method is not allowed).
**Important**: The challenge must be base64 encoded, **with URL encoding** and **without padding**.
Here's a Javascript example of a verifier and challenge generation:
```js
function generateRandomString() {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
let res = ""
const buf = new Uint8Array(64)
crypto.getRandomValues(buf)
for (let i in buf) {
res += alphabet[buf[i] % alphabet.length]
}
return res
}
async function pkceChallengeFromVerifier(v) {
const b = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v))
return btoa(String.fromCharCode(...new Uint8Array(b)))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "")
}
const verifier = generateRandomString()
pkceChallengeFromVerifier(verifier).then(challenge => {
console.log(verifier)
console.log(challenge)
})
```
### Workflow
Authorization flow:
```
+-------+ +---------+ +---------------+ +-----+
| User | | Client | | Authorization | | API |
+-------+ +---------+ +---------------+ +-----+
| | | |
| Enter instance URL | | |
|------------------------>| | |
| | | |
| | Generate PKCE verifier and challenge | |
| |------------------------------------- | |
| | | | |
| |<------------------------------------ | |
| | | |
| | Open authorization URL | |
| | GET /authorize?... | |
| |--------------------------------------------->| |
| | | |
| Redirect to login/authorization prompt | |
|<-----------------------------------------------------------------------| |
| | | |
| Authorize Client | | |
| POST /authorize?... | |
|----------------------------------------------------------------------->| |
| | | |
| | Authorization code | |
| |<---------------------------------------------| |
| | | |
| | Check state | |
| |------------ | |
| | | | |
| |<----------- | |
| | | |
| | Request Token (with code and verifier) | |
| | POST /api/oauth/token | |
| |--------------------------------------------->| |
| | | |
| | | Check PKCE |
| | |----------- |
| | | | |
| | |<---------- |
| | | |
| | Access Token | |
| |<---------------------------------------------| |
| | | |
| | Request data with Access Token | |
| |------------------------------------------------------------->|
| | | |
| | | Response |
| |<-------------------------------------------------------------|
| | | |
```

View File

@@ -34,125 +34,10 @@ routes:
summary: Client Create
description: |
This route creates a new OAuth client. You must create a new client before you can request permissions and a token.
Upon registration, you'll receive a `client_id` and an `registration_access_token`.
You'll need them if you want to fetch, update or delete the client later.
You must keep this information and store them as safely as a password.
This route creates a new OAuth client. You must create a new client before you can request permissions and receive a token.
This route implements [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591).
Client registration flow:
```
+---------+ +---------------+
| Client | | Registration |
+---------+ +---------------+
| |
| Client Registration Request |
| POST /api/oauth/client |
|------------------------------------>|
| |
| Client Information Response |
|<------------------------------------|
| |
```
Client management flow:
```
+---------+ +---------------+
| Client | | Registration |
+---------+ +---------------+
| |
| Client Information Request |
| GET /api/oauth/client/{id} |
|------------------------------------>|
| |
| Client Information Response |
|<------------------------------------|
| |
| Read or Update Request |
| GET or PUT /api/oauth/client/{id} |
|------------------------------------>|
| |
| Client Information Response |
|<------------------------------------|
| |
| Delete Request |
| DELETE /api/oauth/client/{id} |
|------------------------------------>|
| |
| Delete Confirmation |
|<------------------------------------|
| |
```
See also:
- [Client Info](#get-/oauth/client/-id-)
- [Client Update](#put-/oauth/client/-id-)
- [Client Delete](#delete-/oauth/client/-id-)
Here's, in Javascript, an example of a client flow for an app:
```js
async function clientFlow() {
// Where you store the client_id and registration_access_token
const appVersion = "1.1.0"
const store = {}
let rsp
if (!store.clientID) {
// New client, create one
rsp = await fetch(
"__BASE_URI__/oauth/client",
{
"method": "POST",
"body": json.Stringify({
"client_name": "My new client",
"client_uri": "https://example.org/",
"redirect_uris": [
"https://example.org/callback"
],
"software_id": "some-uuid",
"software_version": "1.0.0",
})
},
)
let data = await rsp.json()
store.clientID = data["client_id"]
store.registrationAccessToken = data["registration_access_token"]
// we're done
return
}
// We have a client id, check if we need to update it
rsp = await fetch(
`__BASE_URI__/oauth/client/${store.clientID}`,
{
"headers": {"Authorization": `Bearer ${store.registrationAccessToken}`}
},
)
let data = await rsp.json()
if (data["software_version"] != appVersion) {
// We need to update the client
rsp = await fetch(
`__BASE_URI__/oauth/client/${store.clientID}`,
{
"method": "PUT",
"body": json.Stringify({
...data,
"software_version": appVersion,
}),
"headers": {"Authorization": `Bearer ${store.registrationAccessToken}`}
},
)
let data = await rsp.json()
}
}
```
requestBody:
content:
application/json:
@@ -182,7 +67,7 @@ routes:
summary: Client Info
description: |
This route returns a registered client information. You must pass the received `registration_access_token`
after creating the client in a `Authorization: Beare registration_access_token` header.
after creating the client in a `Authorization: Bearer registration_access_token` header.
This route implements [RFC 7592: OAuth 2.0 Dynamic Client Registration Management Protocol](https://datatracker.ietf.org/doc/html/rfc7592).
@@ -250,134 +135,6 @@ routes:
A client must call this route once it received the necessary information from the
redirect URI generated by the authorization route.
### Authorization
The authorization route is: `__ROOT_URI__/authorize` and it receives the following
query parameters:
| Name | Description
| :---------------------- | :-----------------------------
| `client_id` | OAuth Client ID
| `redirect_uri` | Redirection URI
| `scope` | Space separated list of scopes
| `code_challenge` | PKCE Challenge
| `code_challenge_method` | Only `S256` is allowed
| `state` | Optional client state
Sending a state is not mandatory but strongly advised to ensure that your side of the
authorization flow has not been tampered.
### Authorization result
Once a user grants or denies an authorization request, it will be redirected to the
`redirect_uri` with the following query parameters:
| Name | Description
| :------ | :--------------------------------------------------------------------
| `code` | The authorization code that the client must pass to the token request
| `state` | The state as initially set by the client
In case of error (request denied by the user or something else), the redirection contains
the following query parameters:
| Name | Description
| :------------------ | :--------------------------------------------------------------------
| `error` | Error code (can be `invalid_request` or `access_denied`)
| `error_description` | Error description
### PKCE
The authorization code flow requires that you use [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
with an S256 method only (the "plain" method is not allowed).
**Important**: The challenge must be base64 encoded, **with URL encoding** and **without padding**.
Here's a Javascript example of a verifier and challenge generation:
```js
function generateRandomString() {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
let res = ""
const buf = new Uint8Array(64)
crypto.getRandomValues(buf)
for (let i in buf) {
res += alphabet[buf[i] % alphabet.length]
}
return res
}
async function pkceChallengeFromVerifier(v) {
const b = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v))
return btoa(String.fromCharCode(...new Uint8Array(b)))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "")
}
const verifier = generateRandomString()
pkceChallengeFromVerifier(verifier).then(challenge => {
console.log(verifier)
console.log(challenge)
})
```
### Workflow
Authorization flow:
```
+-------+ +---------+ +---------------+ +-----+
| User | | Client | | Authorization | | API |
+-------+ +---------+ +---------------+ +-----+
| | | |
| Enter instance URL | | |
|------------------------>| | |
| | | |
| | Generate PKCE verifier and challenge | |
| |------------------------------------- | |
| | | | |
| |<------------------------------------ | |
| | | |
| | Open authorization URL | |
| | GET /authorize?... | |
| |--------------------------------------------->| |
| | | |
| Redirect to login/authorization prompt | |
|<-----------------------------------------------------------------------| |
| | | |
| Authorize Client | | |
| POST /authorize?... | |
|----------------------------------------------------------------------->| |
| | | |
| | Authorization code | |
| |<---------------------------------------------| |
| | | |
| | Check state | |
| |------------ | |
| | | | |
| |<----------- | |
| | | |
| | Request Token (with code and verifier) | |
| | POST /api/oauth/token | |
| |--------------------------------------------->| |
| | | |
| | | Check PKCE |
| | |----------- |
| | | | |
| | |<---------- |
| | | |
| | Access Token | |
| |<---------------------------------------------| |
| | | |
| | Request data with Access Token | |
| |------------------------------------------------------------->|
| | | |
| | | Response |
| |<-------------------------------------------------------------|
| | | |
```
requestBody:
content:
application/json:

View File

@@ -76,13 +76,13 @@ schemas:
description: Client supported response type
oauthClientUpdate:
$merge:
- "#.schemas.oauthClientCreate"
required: [client_id, client_name, client_uri, software_id, software_version, redirect_uris]
properties:
client_id:
type: string
description: Client's ID
$merge:
- "#.schemas.oauthClientCreate.properties"
oauthClientResponse:
properties:

View File

@@ -3,6 +3,11 @@
#
# SPDX-License-Identifier: AGPL-3.0-only
admin-only:
x-badges:
- color: red
label: admin only
authenticated:
responses:
"401":
@@ -21,6 +26,10 @@ authenticated:
$ref: "#/components/schemas/message"
paginated:
x-badges:
- color: purple
label: paginated
parameters:
- name: limit
in: query