diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 4e54153..6b39396 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -21,3 +21,5 @@ jobs: - run: pip install pyyaml - name: Check the json, yaml and bash blocks in the Markdown files run: python3 .github/scripts/check_examples.py + - name: Run the Harbor Library examples against the sandbox + run: python3 api-reference/harbor-library/tests/run_examples.py diff --git a/README.md b/README.md index 159d9ed..1221637 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,13 @@ Five samples, ranked by what they prove: ### API reference -Published help center documentation. I wrote these. +**New: a complete API reference I designed and directed, with examples that run.** Then the published help center documentation I wrote. + +| Sample | What it shows | My role | +|---|---|---| +| [Harbor Library Lending API](api-reference/harbor-library/README.md) | A full reference for a made-up API: quickstart, authentication, idempotency, pagination, errors, rate limits, OpenAPI file. A sandbox runs every example, and a GitHub Action fails if one stops matching. | Designed and directed. Drafted with Claude, reviewed by four reader personas, edited by me | + +**Published snapshots** of help center pages I wrote. They are copies of the published articles. Their examples ran in the vendor's online API reference, so they are not runnable here. The Harbor Library sample above is the one you can run. | Sample | What it shows | |---|---| diff --git a/api-reference/get-company-members.md b/api-reference/get-company-members.md index 4226e9e..0e4e7ed 100644 --- a/api-reference/get-company-members.md +++ b/api-reference/get-company-members.md @@ -6,6 +6,8 @@ original_format: PDF (published help center article) # Get Company Members +> **Snapshot.** This is a copy of a published help center article, kept as I wrote it. Its examples ran in the vendor's online API reference, where the tabs mentioned below showed code in each language. Those tabs are not part of this copy, and the endpoint has since changed. For a reference whose examples run, see the [Harbor Library Lending API](harbor-library/README.md). + How to use the company members endpoint to retrieve member information. The company members endpoint retrieves the name, email address, and other details for a member of your company using the parameters you choose. diff --git a/api-reference/harbor-library/README.md b/api-reference/harbor-library/README.md new file mode 100644 index 0000000..b63aafa --- /dev/null +++ b/api-reference/harbor-library/README.md @@ -0,0 +1,31 @@ +# Harbor Library Lending API: a documentation sample + +A complete, small API reference for a library lending service: quickstart, authentication, pagination, reference pages, errors, rate limits, and a changelog. The API is made up. **Every example in these pages runs.** + +**Written for** developers who build against an API and want to see how I document one. **To show** the parts of API documentation that decide whether a developer succeeds on the first try: a working first call, an errors page that tells you what to do next, and examples that cannot drift from the truth. + +## What to open first + +1. [Quickstart](docs/quickstart.md): a first loan in about five minutes. +2. [Errors](docs/errors.md): every error code, its cause, and its fix. +3. [Loans reference](docs/reference/loans.md): the endpoint with the most going on. +4. [OpenAPI file](openapi.yaml): the source of truth the pages follow. + +The rest: [Authentication](docs/authentication.md), [Idempotency](docs/idempotency.md), [Pagination](docs/pagination.md), [Books](docs/reference/books.md), [Members](docs/reference/members.md), [Rate limits](docs/rate-limits.md), [Changelog](docs/changelog.md), and [Easter eggs](docs/easter-eggs.md), which are real and listed so nobody mistakes them for bugs. + +## Why the examples cannot go stale + +`mock/server.py` is a sandbox with no dependencies. `tests/run_examples.py` finds every `curl` example in the pages, runs it against the sandbox, and fails if the answer differs from the response shown. It also fails if a status is not one the OpenAPI file lists for that route, or if an error code the sandbox can return is missing from the errors page. A GitHub Action runs it on every change. + +Each page assumes a fresh sandbox, and the check restarts the sandbox for every page. Run it yourself: + +```bash +pip install pyyaml +python3 api-reference/harbor-library/tests/run_examples.py +``` + +## How this was made + +I designed the API and directed the work. An AI tool (Claude) drafted the pages and the sandbox. Four AI reader personas then reviewed the pages over several rounds, and I edited and approved the result. The sandbox and the checks are what keep the AI-drafted parts honest. + +The library, its members, and its books are invented. Real authors and titles appear only as sample data. diff --git a/api-reference/harbor-library/docs/authentication.md b/api-reference/harbor-library/docs/authentication.md new file mode 100644 index 0000000..7fc6a76 --- /dev/null +++ b/api-reference/harbor-library/docs/authentication.md @@ -0,0 +1,69 @@ +# Authentication + +**Written for** a developer who is about to make the first real call, **to** send the API key correctly and understand the two ways it can fail. + +Every endpoint except the [teapot](easter-eggs.md) needs an API key. Send it in the `Authorization` header with the word `Bearer` in front. `Bearer` means "whoever carries this key is allowed in." + +```bash +curl "http://127.0.0.1:8080/v1/members/mem_ada" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "mem_ada", + "name": "Ada Lovelace", + "card_status": "active", + "fines_cents": 0, + "open_loans": 1 +} +``` + +The sandbox accepts one key, `hl_test_sandbox`. It is public on purpose and only works against the sandbox. + +Keep a real key out of source code, URLs, and logs. Anyone who holds it can lend and return books as your library. + +## When the key is missing or wrong + +A request with no `Authorization` header, or one that does not start with `Bearer`, gets `401` and the code [`unauthorized`](errors.md#unauthorized) (the header is missing, or it does not start with `Bearer`): + +```bash +curl "http://127.0.0.1:8080/v1/members/mem_ada" +``` + +```json +{ + "error": { + "status": 401, + "code": "unauthorized", + "message": "Send your API key as: Authorization: Bearer YOUR_KEY.", + "doc_url": "https://docs.harborlibrary.example/errors#unauthorized", + "request_id": "req_000002" + } +} +``` + +A request with a key the API does not recognize also gets `401`, with the code [`invalid_api_key`](errors.md#invalid_api_key): + +```bash +curl "http://127.0.0.1:8080/v1/members/mem_ada" \ + -H "Authorization: Bearer hl_test_wrong" +``` + +```json +{ + "error": { + "status": 401, + "code": "invalid_api_key", + "message": "That API key is not recognized.", + "doc_url": "https://docs.harborlibrary.example/errors#invalid_api_key", + "request_id": "req_000003" + } +} +``` + +Fix: check that the header starts with `Bearer `, and that the key is copied whole, with no spaces or line breaks. Do not retry a `401` unchanged. + +## Next + +[Pagination](pagination.md), or the [quickstart](quickstart.md) if you have not run it yet. diff --git a/api-reference/harbor-library/docs/changelog.md b/api-reference/harbor-library/docs/changelog.md new file mode 100644 index 0000000..c5ee91d --- /dev/null +++ b/api-reference/harbor-library/docs/changelog.md @@ -0,0 +1,15 @@ +# Changelog + +**Written for** a developer who needs to know whether an update can break their code, **to** read what changed and when. + +Changes to the Harbor Library Lending API, newest first. + +## How versions work + +The `v1` at the start of every path is the **major version**. A change that could break existing code, such as removing a field or changing what a status code means, gets a new major version at a new path, such as `/v2`. Adding a field or an endpoint does not. Your code should ignore fields it does not know. + +When a new major version ships, the old one keeps working for at least 12 months. The release is announced here first. The version number in this file follows [semantic versioning](https://semver.org). + +## 1.0.0 (2026-10-26) + +First release. Books, members, and loans. Cursor pagination. Idempotent loan creation. Error codes and rate limits as documented here. diff --git a/api-reference/harbor-library/docs/easter-eggs.md b/api-reference/harbor-library/docs/easter-eggs.md new file mode 100644 index 0000000..9a5c03e --- /dev/null +++ b/api-reference/harbor-library/docs/easter-eggs.md @@ -0,0 +1,60 @@ +# Easter eggs + +**Written for** anyone who found a strange response in the sandbox, **to** confirm it is a joke on purpose and not a bug. + +The sandbox has a few jokes. They are real, they are stable, and they are listed here. None of them can happen by accident. + +## Ask for coffee + +`GET /v1/teapot` needs no API key and always returns `418`. Other methods on that path return `405`, with or without a key. The status comes from [RFC 2324](https://www.rfc-editor.org/rfc/rfc2324), the Hyper Text Coffee Pot Control Protocol, an April Fools' joke from 1998 that web servers have kept ever since. + +```bash +curl "http://127.0.0.1:8080/v1/teapot" +``` + +```json +{ + "error": { + "status": 418, + "code": "teapot", + "message": "I'm a teapot, short and stout. The library does not brew coffee.", + "doc_url": "https://docs.harborlibrary.example/errors#teapot", + "request_id": "req_000001" + } +} +``` + +## The answer + +Book `bk_042` is *The Hitchhiker's Guide to the Galaxy*. The library owns 42 copies: + +```bash +curl "http://127.0.0.1:8080/v1/books/bk_042" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "bk_042", + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + "copies_total": 42, + "copies_available": 42 +} +``` + +## Quiet, please + +The rate limit error is called [`shush`](errors.md#shush), and its message asks you to be quiet. See [rate limits](rate-limits.md). + +## Rip Van Winkle + +Member `mem_rip` borrowed *Frankenstein* on 2006-03-14 and has not returned it. His fines are $1,240.00, so his card is blocked. See [members](reference/members.md) and the [`member_blocked`](errors.md#member_blocked) error. + +## The librarian + +Every response carries the header `X-Librarian: Marian`. Check with `curl -i`. It is a nod to the librarian in *The Music Man*. + +## Next + +[Back to the quickstart](quickstart.md). diff --git a/api-reference/harbor-library/docs/errors.md b/api-reference/harbor-library/docs/errors.md new file mode 100644 index 0000000..cec1e88 --- /dev/null +++ b/api-reference/harbor-library/docs/errors.md @@ -0,0 +1,232 @@ +# Errors + +**Written for** a developer who just got an error, or a support engineer helping a customer who did, **to** find the code, learn the cause, and know exactly what to do next. + +When a request fails, the HTTP status tells you the kind of failure, and the body tells you exactly what happened. Every error has the same shape: + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans" \ + -H "Authorization: Bearer hl_test_sandbox" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: rip-bk003-2026-10-26" \ + -d '{"member_id": "mem_rip", "book_id": "bk_003"}' +``` + +```json +{ + "error": { + "status": 403, + "code": "member_blocked", + "message": "Rip Van Winkle owes $1,240.00 in fines. A card with more than $10.00 in fines is blocked.", + "param": "member_id", + "doc_url": "https://docs.harborlibrary.example/errors#member_blocked", + "request_id": "req_000001" + } +} +``` + +| Field | Meaning | +|---|---| +| `status` | The HTTP status, repeated so you can log the body alone. | +| `code` | A short, stable name for the problem. Write your `if` statements on this. It will not change. | +| `message` | A sentence for a developer. It can change, so do not match on it, and do not show it to a library patron as it is. | +| `param` | The input that caused the error. Only present when there is one. | +| `doc_url` | The section of this page that explains the code. | +| `request_id` | Quote this when you contact support. Every response also has it in the `X-Request-Id` header. | + +## What to do with each status + +- **4xx** means the request needs a change. Do not retry it unchanged. Three codes can succeed later with no change to the request: [`shush`](#shush) after a wait, [`book_unavailable`](#book_unavailable) after a return, and [`member_blocked`](#member_blocked) after the fines are paid. + +A **patron** is a library member, the person standing at the desk. The "Tell the patron" lines below are suggested words for them. +- **429** means slow down. Wait, then retry. See [rate limits](rate-limits.md). +- **5xx** means the library's system had a problem. Retry with the same `Idempotency-Key`. See [5xx errors](#5xx-errors). + +## Contacting support + +If you cannot solve it with this page, send the developer desk these four things: + +1. The `request_id` from the error body, or the `X-Request-Id` header. +2. The time of the request, with the time zone. +3. The method and path, such as `POST /v1/loans`. +4. The error `code`. + +The desk is at `https://docs.harborlibrary.example/support`. Never send your API key. + +## When more than one error applies + +The API checks in a fixed order and reports the first problem it finds. + +**Every request:** the API key comes first, so a bad key beats every other error. Then the path and method (`route_not_found`, `method_not_allowed`). The one exception is `GET /v1/teapot`, which needs no key. + +**Lend (`POST /v1/loans`), after the key and path:** the body is valid JSON (`invalid_json`), then `member_id` and `book_id` are present and are strings (`invalid_request`), then the `Idempotency-Key` is 1 to 64 characters (`invalid_request`), then the key was not used for a different lend (`idempotency_key_reused`), then the member exists, then the book exists, then the member is not blocked, then a copy is available. + +**Renew, after the key and path:** the loan exists, then it is not already returned, then it is not overdue, then it has renewals left. So an overdue loan that has used both renewals reports `loan_overdue`. + +**Return, after the key and path:** the loan exists, then it is not already returned. + +Renew and return ignore any request body. + +## 400 errors + +### `invalid_request` + +- **Cause:** A parameter is missing, the wrong type, or out of range. The `param` field names it. +- **Fix:** Correct that parameter and send the request again. +- **Retry?** Not unchanged. + +### `invalid_json` + +- **Cause:** The body is not valid JSON, or it is not a JSON object. +- **Fix:** Check for a trailing comma or a missing quote. +- **Retry?** Not unchanged. + +### `invalid_cursor` + +- **Cause:** The `cursor` is not one the API gave you. +- **Fix:** Start again from the first page. See [pagination](pagination.md). +- **Retry?** Not unchanged. + +## 401 errors + +### `unauthorized` + +- **Cause:** There is no `Authorization` header, or it does not start with `Bearer`. +- **Fix:** Add it. See [authentication](authentication.md). +- **Retry?** Not unchanged. + +### `invalid_api_key` + +- **Cause:** The key is not recognized. +- **Fix:** Copy the key again, with no spaces or line breaks. +- **Retry?** Not unchanged. + +## 403 errors + +### `member_blocked` + +- **Cause:** The member owes more than $10.00 in fines. The amount is `fines_cents` on the [member](reference/members.md). +- **Fix:** The member pays at the front desk. The card works again when fines are $10.00 or less. Then try again. +- **Retry?** Only after the member has paid. +- **Tell the patron:** "Your card has more than $10.00 in fines. Please see the front desk." + +## 404 errors + +### `book_not_found` + +- **Cause:** No book has that id. +- **Fix:** Check the id. +- **Retry?** Not unchanged. +- **Tell the patron:** "We don't have that title in the catalog. Let's search by author." + +### `member_not_found` + +- **Cause:** No member has that id. +- **Fix:** Check the id. +- **Retry?** Not unchanged. +- **Tell the patron:** "I can't find that library card. Can you read me the number again?" + +### `loan_not_found` + +- **Cause:** No loan has that id. +- **Fix:** Check the id. +- **Retry?** Not unchanged. + +### `route_not_found` + +- **Cause:** There is no endpoint at that path. +- **Fix:** Check the path. +- **Retry?** Not unchanged. + +## 405 errors + +### `method_not_allowed` + +- **Cause:** The path exists, but not for that method, such as `DELETE /v1/loans/loan_0002`. The API has no delete. +- **Fix:** Check the method against the [reference](reference/loans.md). +- **Retry?** Not unchanged. + +## 409 errors + +The request was fine, but the library's records do not allow it right now. + +### `book_unavailable` + +- **Cause:** Every copy is on loan. +- **Fix:** Offer another book, or check again after a return. A retry with the same `Idempotency-Key` is safe. +- **Retry?** Later, yes. +- **Tell the patron:** "All copies are out right now. Would you like another book?" + +### `loan_not_active` + +- **Cause:** The book was already returned. This is also what a second `return` on the same loan gets. It means the first one worked. +- **Fix:** Nothing to do. The loan is finished. Treat it as success if you were returning the book. +- **Retry?** No. +- **Tell the patron:** "That book is already checked in." + +### `loan_overdue` + +- **Cause:** You tried to renew a loan that is past its due date. +- **Fix:** Return the book. An overdue loan cannot be renewed. +- **Fines:** This API does not charge a fine for returning late. Any fine is set by the front desk and appears in `fines_cents` on the member. +- **Retry?** Not until the book is returned and lent again. +- **Tell the patron:** "This book is past due, so it can't be renewed. Please return it at the desk." + +### `renewal_limit_reached` + +- **Cause:** The loan was already renewed twice. +- **Fix:** The patron returns the book. After that, the same patron may borrow it again if a copy is on the shelf. +- **Retry?** No. Another renew will fail the same way. +- **Tell the patron:** "You've renewed this book twice, which is the limit. Please bring it back, and you can borrow it again if it's on the shelf." + +### `idempotency_key_reused` + +- **Cause:** The `Idempotency-Key` was already used for a different lend. +- **Fix:** Use a new key for each different lend. Only repeat a key when you are retrying the same lend. See [idempotency](idempotency.md). +- **Retry?** With a new key. + +## 418 error + +### `teapot` + +- **Cause:** You asked the library for coffee at `GET /v1/teapot`. +- **Fix:** Have some tea. See [Easter eggs](easter-eggs.md). +- **Retry?** You will get the same answer. + +## 429 error + +### `shush` + +- **Cause:** More than 60 requests in a minute. +- **Fix:** Wait for the number of seconds in the `Retry-After` header, then retry. See [rate limits](rate-limits.md). +- **Retry?** Yes, after the wait. + +## 5xx errors + +A `5xx` response is a problem on the library's side, not yours. It has the same shape. The code is `internal_error` for `500` and `unavailable` for `503`. + +### `internal_error` + +A `500`. Something broke on the library's side. Handle it the way the next section describes. + +### `unavailable` + +A `503`. The library system is busy or down for a moment. Here is a full example: + +```json +{ + "error": { + "status": 503, + "code": "unavailable", + "message": "The library system is busy. Try again in a moment.", + "doc_url": "https://docs.harborlibrary.example/errors#unavailable", + "request_id": "req_000207" + } +} +``` + +- **Cause:** An outage or a bug on the library's side. +- **Fix:** Wait a few seconds and retry. For `POST /v1/loans`, send the same `Idempotency-Key`, so a lend that did go through is not made twice. **Do not blindly retry a renew.** The first one may have worked, and each renew adds 14 days. Read the loan with `GET /v1/loans/{loan_id}` and check `renewals` first. If it keeps failing, [contact support](#contacting-support) with the `request_id`. +- **Retry?** Yes, with a growing wait, up to five tries. Lend and return are safe to retry. Renew needs the check above. + +The sandbox never returns a `5xx`. diff --git a/api-reference/harbor-library/docs/idempotency.md b/api-reference/harbor-library/docs/idempotency.md new file mode 100644 index 0000000..a889222 --- /dev/null +++ b/api-reference/harbor-library/docs/idempotency.md @@ -0,0 +1,103 @@ +# Idempotency + +**Written for** a developer whose code might send the same "lend a book" request twice, **to** make sure a retry can never lend a book twice. + +Networks fail. If your request times out, you do not know whether the library received it. If you send it again and it did, the member has two loans for one request. + +The fix is the `Idempotency-Key` header on `POST /v1/loans`. Pick a unique string for each lend you intend to make, and send it again unchanged on every retry of that same lend. + +## How it works + +- **First request with a key:** the loan is created and the response is stored under the key. +- **Same key, same body:** you get the first response back with status `201`, and the extra header `Idempotent-Replay: true`. No second loan is made. "Same body" means the same JSON content. Key order and spacing do not matter. The response shows the loan as it was when it was created, even if the book has been returned since. +- **Same key, different body:** you get `409` with the code [`idempotency_key_reused`](errors.md#idempotency_key_reused). This usually means your code reused a key by mistake. Use a new key. +- **Failed request:** only successful lends are stored. If a request fails, for example with `book_unavailable`, fix the problem and retry with the same key. It is treated as a first request. +- **Length:** 1 to 64 characters. A longer key returns `400` with the code [`invalid_request`](errors.md#invalid_request). +- **How long keys last:** 24 hours. After that, the same key starts a new lend. The sandbox does not expire keys. It forgets them when you restart it. + +## Try it + +Lend a book with a key: + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans" \ + -H "Authorization: Bearer hl_test_sandbox" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: ada-bk004-2026-10-26" \ + -d '{"member_id": "mem_ada", "book_id": "bk_004"}' +``` + +```json +{ + "id": "loan_0005", + "member_id": "mem_ada", + "book_id": "bk_004", + "status": "active", + "borrowed_on": "2026-10-26", + "due_on": "2026-11-16", + "returned_on": null, + "renewals": 0 +} +``` + +Send the identical request again. The loan is the same, and `bk_004` is not lent a second time. The body is identical, so the proof is in the headers. Add `-i` to the command and you will see these two lines among them: + +```text +Idempotent-Replay: true +Location: /v1/loans/loan_0005 +``` + +The command: + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans" \ + -H "Authorization: Bearer hl_test_sandbox" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: ada-bk004-2026-10-26" \ + -d '{"member_id": "mem_ada", "book_id": "bk_004"}' +``` + +```json +{ + "id": "loan_0005", + "member_id": "mem_ada", + "book_id": "bk_004", + "status": "active", + "borrowed_on": "2026-10-26", + "due_on": "2026-11-16", + "returned_on": null, + "renewals": 0 +} +``` + +Now reuse the key for a different lend: + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans" \ + -H "Authorization: Bearer hl_test_sandbox" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: ada-bk004-2026-10-26" \ + -d '{"member_id": "mem_grace", "book_id": "bk_003"}' +``` + +```json +{ + "error": { + "status": 409, + "code": "idempotency_key_reused", + "message": "That Idempotency-Key was already used with a different request. Use a new key.", + "param": "Idempotency-Key", + "doc_url": "https://docs.harborlibrary.example/errors#idempotency_key_reused", + "request_id": "req_000003" + } +} +``` + +## The other loan endpoints + +- **Return** does no harm if you repeat it. The second call changes nothing and gets `409` with [`loan_not_active`](errors.md#loan_not_active), which means the book was already returned. If you were returning the book, treat that `409` as success. +- **Renew is not safe to repeat.** Each call adds 14 days, up to two renewals. If a renew times out, read the loan with `GET /v1/loans/{loan_id}` and check `renewals` before you try again. + +## Next + +[Errors](errors.md), then the [loans reference](reference/loans.md). diff --git a/api-reference/harbor-library/docs/pagination.md b/api-reference/harbor-library/docs/pagination.md new file mode 100644 index 0000000..32d90da --- /dev/null +++ b/api-reference/harbor-library/docs/pagination.md @@ -0,0 +1,85 @@ +# Pagination + +**Written for** a developer who needs to read a list that is longer than one response, **to** page through it correctly to the end. + +List endpoints return one page at a time. Use `limit` to choose the page size, from 1 to 100. The default is 20. + +Every list response has a `data` array and two more fields: + +| Field | Meaning | +|---|---| +| `has_more` | `true` if another page exists. | +| `next_cursor` | Pass this value as `cursor` to get the next page. `null` on the last page. | + +Ask for two books at a time: + +```bash +curl "http://127.0.0.1:8080/v1/books?limit=2" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "data": [ + { + "id": "bk_001", + "title": "Frankenstein", + "author": "Mary Shelley", + "copies_total": 2, + "copies_available": 1 + }, + { + "id": "bk_002", + "title": "A Wizard of Earthsea", + "author": "Ursula K. Le Guin", + "copies_total": 1, + "copies_available": 0 + } + ], + "has_more": true, + "next_cursor": "cur_2" +} +``` + +`has_more` is `true`, so ask for the next page. Pass `next_cursor` back exactly as you received it: + +```bash +curl "http://127.0.0.1:8080/v1/books?limit=2&cursor=cur_2" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "data": [ + { + "id": "bk_003", + "title": "The Left Hand of Darkness", + "author": "Ursula K. Le Guin", + "copies_total": 3, + "copies_available": 3 + }, + { + "id": "bk_004", + "title": "Kindred", + "author": "Octavia E. Butler", + "copies_total": 1, + "copies_available": 1 + } + ], + "has_more": true, + "next_cursor": "cur_4" +} +``` + +Keep going until `has_more` is `false`. + +## Rules + +- Treat a cursor as a sealed envelope. Do not build one, change one, or save one for later. The sandbox cursors look like `cur_2`. The real ones will not, so never depend on the format. +- Send the same filters on every page. A cursor remembers only your place in the list, not your search. If you change `q`, `status`, or any other filter between pages, you get whatever is at that place in the new list, often an empty page. Start again from the first page when you change a filter. +- A cursor that is not in the format the API uses returns `400` with the code [`invalid_cursor`](errors.md#invalid_cursor). A cursor in the right format that points past the end of the list returns an empty page, with `has_more` set to `false`. Either way, start again from the first page. +- A bad `limit`, such as `0` or `500`, returns `400` with the code [`invalid_request`](errors.md#invalid_request). + +## Next + +[Books](reference/books.md) and [loans](reference/loans.md) are the two lists you can page through. diff --git a/api-reference/harbor-library/docs/quickstart.md b/api-reference/harbor-library/docs/quickstart.md new file mode 100644 index 0000000..f829137 --- /dev/null +++ b/api-reference/harbor-library/docs/quickstart.md @@ -0,0 +1,117 @@ +# Quickstart: lend your first book + +**Written for** a developer building a self-checkout kiosk or a library app, **to** lend a book, check the loan, and return it in about five minutes. + +You need Python 3.10 or newer and `curl`. You do not need an account. The sandbox answers the same requests as the real API, with a small set of sample books and members. + +You will use two terminal windows: one for the sandbox, which stays running, and one for the `curl` commands. + +## 1. Start the sandbox + +In the first terminal, go to the root of the `writing-samples` repository (the folder that contains `api-reference`) and run: + +```bash +python3 api-reference/harbor-library/mock/server.py +``` + +You should see this one line, and then nothing more. That means it is running: + +```text +Harbor Library sandbox on http://127.0.0.1:8080 (API key: hl_test_sandbox) +``` + +Leave this window open. If you see `Address already in use`, something else is using port 8080. Add a different port to the end of the command, such as `... server.py 8090`, and use that port in place of 8080 in every example. + +Every example in these docs uses `http://127.0.0.1:8080`. The real API would use `https://api.harborlibrary.example` instead, and everything else would stay the same. The `v1` in each path is the major version. [The changelog](changelog.md#how-versions-work) explains it. + +## 2. Find a book you can borrow + +Open the second terminal for the rest of this page. + +Every request needs your API key in an `Authorization` header. The word `Bearer` means "whoever carries this key is allowed in." The sandbox key is `hl_test_sandbox`. + +Search for books by Ursula K. Le Guin that have a copy on the shelf: + +```bash +curl "http://127.0.0.1:8080/v1/books?q=guin&available=true" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "data": [ + { + "id": "bk_003", + "title": "The Left Hand of Darkness", + "author": "Ursula K. Le Guin", + "copies_total": 3, + "copies_available": 3 + } + ], + "has_more": false, + "next_cursor": null +} +``` + +One book comes back, `bk_003`. The other Le Guin book in the sandbox, `bk_002`, is checked out, so the filter leaves it out. The `has_more` and `next_cursor` fields tell you whether there are more pages of results. Here there are not. [Pagination](pagination.md) explains them. + +## 3. Lend it + +Lend `bk_003` to the member `mem_grace`. The `Idempotency-Key` header makes the request safe to retry: if your network drops and you send the same request again with the same key, you get the same loan back and the book is not lent twice. [Idempotency](idempotency.md) has the details. + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans" \ + -H "Authorization: Bearer hl_test_sandbox" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: quickstart-grace-bk003" \ + -d '{"member_id": "mem_grace", "book_id": "bk_003"}' +``` + +```json +{ + "id": "loan_0005", + "member_id": "mem_grace", + "book_id": "bk_003", + "status": "active", + "borrowed_on": "2026-10-26", + "due_on": "2026-11-16", + "returned_on": null, + "renewals": 0 +} +``` + +The response status is `201 Created`. Plain `curl` prints only the body. Add `-i` to the command to see the status line. The loan has an `id` and a `due_on` date 21 days from the sandbox's fixed "today", 2026-10-26. Write down the `id`. On a fresh sandbox it is `loan_0005`. If you have run other examples first, yours may differ, so copy it from your own response. + +## 4. Return it + +Use the loan `id` from the last step in place of `loan_0005`: + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans/loan_0005/return" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "loan_0005", + "member_id": "mem_grace", + "book_id": "bk_003", + "status": "returned", + "borrowed_on": "2026-10-26", + "due_on": "2026-11-16", + "returned_on": "2026-10-26", + "renewals": 0 +} +``` + +The loan now has `"status": "returned"` and a `returned_on` date. You have lent a book and taken it back. + +## Start over + +The sandbox keeps its data in memory. To reset it, stop it with `Ctrl+C` in the first terminal and start it again. Each page of these docs assumes a fresh sandbox. + +## Next + +- Something went wrong? [Errors](errors.md) lists every error code and how to fix it. +- Ready to build? Read [authentication](authentication.md) and [idempotency](idempotency.md), then the [loans reference](reference/loans.md). +- Curious what else the sandbox can do? [Easter eggs](easter-eggs.md). diff --git a/api-reference/harbor-library/docs/rate-limits.md b/api-reference/harbor-library/docs/rate-limits.md new file mode 100644 index 0000000..2e8cd35 --- /dev/null +++ b/api-reference/harbor-library/docs/rate-limits.md @@ -0,0 +1,48 @@ +# Rate limits + +**Written for** a developer whose code calls the API in a loop, **to** stay under the limit and recover correctly when you go over. + +Each API key can make 60 requests per minute. Every response says where you stand, in headers: + +| Header | Meaning | +|---|---| +| `X-RateLimit-Limit` | Requests allowed per minute. Always 60. | +| `X-RateLimit-Remaining` | Requests you have left in this minute. The sandbox reports 59 on every response except a forced `429`, because it does not keep count. | +| `Retry-After` | Only on a `429`. Seconds to wait before trying again. | + +Go over the limit and the API answers `429 Too Many Requests` with the code [`shush`](errors.md#shush), and keeps answering that way until the minute is up. + +## See the 429 in the sandbox + +You would need to send 61 requests to hit the limit, and the sandbox does not count. To see the response without that, add the header `X-Sandbox-Force: rate_limited`. The sandbox then answers `429`, with `Retry-After: 3` and `X-RateLimit-Remaining: 0`: + +```bash +curl "http://127.0.0.1:8080/v1/books" \ + -H "Authorization: Bearer hl_test_sandbox" \ + -H "X-Sandbox-Force: rate_limited" +``` + +```json +{ + "error": { + "status": 429, + "code": "shush", + "message": "Shh. This is a library. Wait a moment, then try again.", + "doc_url": "https://docs.harborlibrary.example/errors#shush", + "request_id": "req_000001" + } +} +``` + +## What your code should do + +1. On a `429`, wait the number of seconds in `Retry-After`. Do not retry sooner. +2. Then retry the same request. For `POST /v1/loans`, send the same `Idempotency-Key` so the retry cannot lend the book twice. +3. If you get `429` again, double your last wait. For example, wait 3 seconds, then 6, then 12. Stop after five tries and tell the user. +4. To stay under the limit, list with a large `limit` (up to 100) instead of fetching items one at a time. + +The `message` on a `429` is a joke for developers. Do not show it to a library patron. Show your own text, such as "The system is busy. Please try again in a moment." + +## Next + +[Errors](errors.md) for every other code. diff --git a/api-reference/harbor-library/docs/reference/books.md b/api-reference/harbor-library/docs/reference/books.md new file mode 100644 index 0000000..3830e33 --- /dev/null +++ b/api-reference/harbor-library/docs/reference/books.md @@ -0,0 +1,101 @@ +# Books + +**Written for** a developer who needs to search the catalog or check whether a copy is on the shelf, **to** call the two book endpoints and read their results. + +A book is a title the library owns. It can have many copies. + +## The book object + +| Field | Type | Meaning | +|---|---|---| +| `id` | string | The book's id, such as `bk_042`. | +| `title` | string | The title. | +| `author` | string | The author. | +| `copies_total` | integer | How many copies the library owns. | +| `copies_available` | integer | How many copies are on the shelf right now. | + +## List books + +`GET /v1/books` + +Returns books one page at a time. See [pagination](../pagination.md). + +| Query parameter | Type | Meaning | +|---|---|---| +| `q` | string | Text to find in the title or the author. Not case sensitive. | +| `available` | boolean | `true` returns only books with `copies_available` above 0. `false` or leaving it out returns all books. Any other value returns `400`. | +| `limit`, `cursor` | | See [pagination](../pagination.md). | + +```bash +curl "http://127.0.0.1:8080/v1/books?q=earthsea" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "data": [ + { + "id": "bk_002", + "title": "A Wizard of Earthsea", + "author": "Ursula K. Le Guin", + "copies_total": 1, + "copies_available": 0 + } + ], + "has_more": false, + "next_cursor": null +} +``` + +## Get a book + +`GET /v1/books/{book_id}` + +```bash +curl "http://127.0.0.1:8080/v1/books/bk_004" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "bk_004", + "title": "Kindred", + "author": "Octavia E. Butler", + "copies_total": 1, + "copies_available": 1 +} +``` + +If no book has that id, you get `404` with the code [`book_not_found`](../errors.md#book_not_found): + +```bash +curl "http://127.0.0.1:8080/v1/books/bk_999" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "error": { + "status": 404, + "code": "book_not_found", + "message": "No book has the id bk_999.", + "param": "book_id", + "doc_url": "https://docs.harborlibrary.example/errors#book_not_found", + "request_id": "req_000003" + } +} +``` + +## Errors for these endpoints + +| Status | Code | Cause | +|---|---|---| +| 400 | [`invalid_request`](../errors.md#invalid_request) | `limit` is out of range, or `available` is not `true` or `false`. | +| 400 | [`invalid_cursor`](../errors.md#invalid_cursor) | The `cursor` is not one the API gave you. | +| 404 | [`book_not_found`](../errors.md#book_not_found) | No book has that id. | + +Authentication and rate-limit errors can happen on any request. See [errors](../errors.md). + +## Next + +[Lend a book](loans.md) to a member, or check the [member](members.md) first. diff --git a/api-reference/harbor-library/docs/reference/loans.md b/api-reference/harbor-library/docs/reference/loans.md new file mode 100644 index 0000000..2a40147 --- /dev/null +++ b/api-reference/harbor-library/docs/reference/loans.md @@ -0,0 +1,276 @@ +# Loans + +**Written for** a developer building the lend, renew, and return screens of a library app, **to** call each loan endpoint and handle every way it can fail. + +A loan is one copy of a book lent to one member. + +Examples on this page assume a fresh sandbox, so ids such as `loan_0005` may differ from the ones in the [quickstart](../quickstart.md), which lends a different book. + +## The loan object + +| Field | Type | Meaning | +|---|---|---| +| `id` | string | The loan's id, such as `loan_0002`. | +| `member_id` | string | Who has the book. | +| `book_id` | string | Which book. | +| `status` | string | `active`, `overdue`, or `returned`. A loan turns `overdue` the day after `due_on`. | +| `borrowed_on` | date | The day the book was lent, as `YYYY-MM-DD`. | +| `due_on` | date | The day the book is due back. | +| `returned_on` | date or null | The day it came back. `null` until then. | +| `renewals` | integer | How many times the loan has been renewed, from 0 to 2. | + +## List loans + +`GET /v1/loans` + +| Query parameter | Type | Meaning | +|---|---|---| +| `member_id` | string | Return only this member's loans. | +| `status` | string | `active`, `overdue`, or `returned`. Any other value returns `400` with the code [`invalid_request`](../errors.md#invalid_request). | +| `limit`, `cursor` | | See [pagination](../pagination.md). | + +Find everything Ada Lovelace has not returned yet: + +```bash +curl "http://127.0.0.1:8080/v1/loans?member_id=mem_ada&status=active" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "data": [ + { + "id": "loan_0002", + "member_id": "mem_ada", + "book_id": "bk_002", + "status": "active", + "borrowed_on": "2026-10-12", + "due_on": "2026-11-02", + "returned_on": null, + "renewals": 0 + } + ], + "has_more": false, + "next_cursor": null +} +``` + +**Errors for list and get** + +| Status | Code | Cause | +|---|---|---| +| 400 | [`invalid_request`](../errors.md#invalid_request) | `limit` is out of range, or `status` is not one of the three values. | +| 400 | [`invalid_cursor`](../errors.md#invalid_cursor) | The `cursor` is not one the API gave you. | +| 404 | [`loan_not_found`](../errors.md#loan_not_found) | No loan has that id (get only). | + +Authentication and rate-limit errors can happen on any request. See [errors](../errors.md). + +## Get a loan + +`GET /v1/loans/{loan_id}` + +```bash +curl "http://127.0.0.1:8080/v1/loans/loan_0004" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "loan_0004", + "member_id": "mem_grace", + "book_id": "bk_004", + "status": "overdue", + "borrowed_on": "2026-09-30", + "due_on": "2026-10-21", + "returned_on": null, + "renewals": 2 +} +``` + +This loan was due on 2026-10-21. The sandbox's "today" is 2026-10-26, so its status is `overdue`. + +## Lend a book + +`POST /v1/loans` + +Lends one copy of a book to a member for 21 days. + +| Body field | Type | Meaning | +|---|---|---| +| `member_id` | string, required | Who is borrowing. | +| `book_id` | string, required | Which book. | + +Send `Content-Type: application/json`. The sandbox accepts other types, but the real API will not. + +| Header | Meaning | +|---|---| +| `Idempotency-Key` | Send one on every lend. A unique string, 1 to 64 characters. It makes a retry safe. See [idempotency](../idempotency.md). | + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans" \ + -H "Authorization: Bearer hl_test_sandbox" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: ada-bk004-2026-10-26" \ + -d '{"member_id": "mem_ada", "book_id": "bk_004"}' +``` + +```json +{ + "id": "loan_0005", + "member_id": "mem_ada", + "book_id": "bk_004", + "status": "active", + "borrowed_on": "2026-10-26", + "due_on": "2026-11-16", + "returned_on": null, + "renewals": 0 +} +``` + +`bk_004` had one copy. Now it has none, so the next request for it fails: + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans" \ + -H "Authorization: Bearer hl_test_sandbox" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: grace-bk004-2026-10-26" \ + -d '{"member_id": "mem_grace", "book_id": "bk_004"}' +``` + +```json +{ + "error": { + "status": 409, + "code": "book_unavailable", + "message": "All copies of \"Kindred\" are on loan.", + "param": "book_id", + "doc_url": "https://docs.harborlibrary.example/errors#book_unavailable", + "request_id": "req_000004" + } +} +``` + +Because the lend failed, nothing was stored under that key. You can retry it with the same key once a copy is returned. + +**Errors for this endpoint** + +| Status | Code | Cause | +|---|---|---| +| 400 | [`invalid_request`](../errors.md#invalid_request) | `member_id` or `book_id` is missing or not a string, or the `Idempotency-Key` is not 1 to 64 characters. | +| 400 | [`invalid_json`](../errors.md#invalid_json) | The body is not a JSON object. | +| 403 | [`member_blocked`](../errors.md#member_blocked) | The member owes more than $10.00 in fines. | +| 404 | [`member_not_found`](../errors.md#member_not_found), [`book_not_found`](../errors.md#book_not_found) | The id does not exist. | +| 409 | [`book_unavailable`](../errors.md#book_unavailable) | Every copy is on loan. | +| 409 | [`idempotency_key_reused`](../errors.md#idempotency_key_reused) | The key was already used for a different lend. | + +If more than one of these applies, the API reports the first one in the order [the errors page](../errors.md#when-more-than-one-error-applies) gives. + +## Renew a loan + +`POST /v1/loans/{loan_id}/renew` + +Adds 14 days to the due date. A loan can be renewed twice. An overdue loan cannot be renewed. Renewing is not safe to repeat: each call adds 14 days. Renew ignores any request body. See [idempotency](../idempotency.md#the-other-loan-endpoints). + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans/loan_0002/renew" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "loan_0002", + "member_id": "mem_ada", + "book_id": "bk_002", + "status": "active", + "borrowed_on": "2026-10-12", + "due_on": "2026-11-16", + "returned_on": null, + "renewals": 1 +} +``` + +`due_on` moved from 2026-11-02 to 2026-11-16, and `renewals` is now 1. + +**Errors for this endpoint** + +| Status | Code | Cause | +|---|---|---| +| 404 | [`loan_not_found`](../errors.md#loan_not_found) | The id does not exist. | +| 409 | [`loan_not_active`](../errors.md#loan_not_active) | The book was already returned. | +| 409 | [`loan_overdue`](../errors.md#loan_overdue) | The loan is past due. Return the book first. | +| 409 | [`renewal_limit_reached`](../errors.md#renewal_limit_reached) | The loan was already renewed twice. | + +An overdue loan fails like this: + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans/loan_0004/renew" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "error": { + "status": 409, + "code": "loan_overdue", + "message": "loan_0004 is overdue. Return the book first. An overdue loan cannot be renewed.", + "param": "loan_id", + "doc_url": "https://docs.harborlibrary.example/errors#loan_overdue", + "request_id": "req_000006" + } +} +``` + +## Return a book + +`POST /v1/loans/{loan_id}/return` + +Marks the loan returned and puts the copy back on the shelf. Returning twice does no harm: the second call changes nothing and returns `409` `loan_not_active`, which means the book was already returned. Treat that `409` as success. + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans/loan_0004/return" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "loan_0004", + "member_id": "mem_grace", + "book_id": "bk_004", + "status": "returned", + "borrowed_on": "2026-09-30", + "due_on": "2026-10-21", + "returned_on": "2026-10-26", + "renewals": 2 +} +``` + +**Errors for this endpoint** + +| Status | Code | Cause | +|---|---|---| +| 404 | [`loan_not_found`](../errors.md#loan_not_found) | The id does not exist. | +| 409 | [`loan_not_active`](../errors.md#loan_not_active) | The book was already returned. | + +Returning the same loan twice fails the second time: + +```bash +curl -X POST "http://127.0.0.1:8080/v1/loans/loan_0004/return" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "error": { + "status": 409, + "code": "loan_not_active", + "message": "loan_0004 was already returned on 2026-10-26.", + "param": "loan_id", + "doc_url": "https://docs.harborlibrary.example/errors#loan_not_active", + "request_id": "req_000008" + } +} +``` + +## Next + +Something failed? Read [errors](../errors.md). Building the lend screen? Read [idempotency](../idempotency.md). diff --git a/api-reference/harbor-library/docs/reference/members.md b/api-reference/harbor-library/docs/reference/members.md new file mode 100644 index 0000000..79f6080 --- /dev/null +++ b/api-reference/harbor-library/docs/reference/members.md @@ -0,0 +1,67 @@ +# Members + +**Written for** a developer who needs to check a library card before lending, **to** read a member and know whether they can borrow. + +A member is a person with a library card. + +## The member object + +| Field | Type | Meaning | +|---|---|---| +| `id` | string | The member's id, such as `mem_ada`. | +| `name` | string | The member's name. | +| `card_status` | string | `active`, or `blocked` when the member owes more than $10.00 in fines. It turns `active` again when fines are $10.00 or less. A blocked member cannot borrow. | +| `fines_cents` | integer | Fines owed, in cents. 1240 means $12.40. | +| `open_loans` | integer | How many loans are active or overdue. | + +This API only reports fines. It does not charge them or set the amount. The front desk does that. + +## Get a member + +`GET /v1/members/{member_id}` + +```bash +curl "http://127.0.0.1:8080/v1/members/mem_ada" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "mem_ada", + "name": "Ada Lovelace", + "card_status": "active", + "fines_cents": 0, + "open_loans": 1 +} +``` + +Check `card_status` before you offer to lend a book. Here is a member whose card is blocked: + +```bash +curl "http://127.0.0.1:8080/v1/members/mem_rip" \ + -H "Authorization: Bearer hl_test_sandbox" +``` + +```json +{ + "id": "mem_rip", + "name": "Rip Van Winkle", + "card_status": "blocked", + "fines_cents": 124000, + "open_loans": 1 +} +``` + +`fines_cents` is 124000, which is $1,240.00. If you try to lend this member a book anyway, the lend fails with [`member_blocked`](../errors.md#member_blocked). The errors page says what to tell the member. + +## Errors for this endpoint + +| Status | Code | Cause | +|---|---|---| +| 404 | [`member_not_found`](../errors.md#member_not_found) | No member has that id. | + +Authentication and rate-limit errors can happen on any request. See [errors](../errors.md). + +## Next + +[Lend a book](loans.md) to an active member. diff --git a/api-reference/harbor-library/mock/__pycache__/server.cpython-312.pyc b/api-reference/harbor-library/mock/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000..a3a3141 Binary files /dev/null and b/api-reference/harbor-library/mock/__pycache__/server.cpython-312.pyc differ diff --git a/api-reference/harbor-library/mock/server.py b/api-reference/harbor-library/mock/server.py new file mode 100644 index 0000000..84f4e44 --- /dev/null +++ b/api-reference/harbor-library/mock/server.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Sandbox for the Harbor Library Lending API. + +Standard library only. Run it: python3 mock/server.py [port] +It listens on http://127.0.0.1:8080 by default and answers the same requests the +documentation shows, with the same data. The clock is fixed at 2026-10-26 so the +examples give the same answer every time. + +Sandbox rules (also in docs/authentication.md and docs/rate-limits.md): + - The only accepted API key is hl_test_sandbox + - Send X-Sandbox-Force: rate_limited to get the 429 response on demand. + - State lives in memory. Restart the server to reset it. +""" +import copy +import json +import sys +from datetime import date, timedelta +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +TODAY = date(2026, 10, 26) +API_KEY = "hl_test_sandbox" +LOAN_DAYS = 21 +RENEW_DAYS = 14 +MAX_RENEWALS = 2 +FINE_LIMIT_CENTS = 1000 +DOCS = "https://docs.harborlibrary.example/errors#" + + +def fresh_state(): + return { + "members": { + "mem_ada": {"id": "mem_ada", "name": "Ada Lovelace", "card_status": "active", "fines_cents": 0}, + "mem_grace": {"id": "mem_grace", "name": "Grace Hopper", "card_status": "active", "fines_cents": 0}, + "mem_rip": {"id": "mem_rip", "name": "Rip Van Winkle", "card_status": "blocked", "fines_cents": 124000}, + }, + "books": { + "bk_001": {"id": "bk_001", "title": "Frankenstein", "author": "Mary Shelley", "copies_total": 2, "copies_available": 1}, + "bk_002": {"id": "bk_002", "title": "A Wizard of Earthsea", "author": "Ursula K. Le Guin", "copies_total": 1, "copies_available": 0}, + "bk_003": {"id": "bk_003", "title": "The Left Hand of Darkness", "author": "Ursula K. Le Guin", "copies_total": 3, "copies_available": 3}, + "bk_004": {"id": "bk_004", "title": "Kindred", "author": "Octavia E. Butler", "copies_total": 1, "copies_available": 1}, + "bk_042": {"id": "bk_042", "title": "The Hitchhiker's Guide to the Galaxy", "author": "Douglas Adams", "copies_total": 42, "copies_available": 42}, + }, + "loans": { + "loan_0001": {"id": "loan_0001", "member_id": "mem_rip", "book_id": "bk_001", "status": "overdue", + "borrowed_on": "2006-03-14", "due_on": "2006-04-04", "returned_on": None, "renewals": 0}, + "loan_0002": {"id": "loan_0002", "member_id": "mem_ada", "book_id": "bk_002", "status": "active", + "borrowed_on": "2026-10-12", "due_on": "2026-11-02", "returned_on": None, "renewals": 0}, + "loan_0003": {"id": "loan_0003", "member_id": "mem_ada", "book_id": "bk_003", "status": "returned", + "borrowed_on": "2026-08-10", "due_on": "2026-08-31", "returned_on": "2026-08-28", "renewals": 0}, + "loan_0004": {"id": "loan_0004", "member_id": "mem_grace", "book_id": "bk_004", "status": "active", + "borrowed_on": "2026-09-30", "due_on": "2026-10-21", "returned_on": None, "renewals": 2}, + }, + "next_loan": 5, + "next_request": 1, + "idempotency": {}, + } + + +STATE = fresh_state() + + +class ApiError(Exception): + def __init__(self, status, code, message, param=None): + self.status, self.code, self.message, self.param = status, code, message, param + + +def page(items, query): + """Cursor pagination. A cursor is opaque to clients; here it encodes an offset.""" + try: + limit = int(query.get("limit", "20")) + except ValueError: + raise ApiError(400, "invalid_request", "limit must be a whole number from 1 to 100.", "limit") + if not 1 <= limit <= 100: + raise ApiError(400, "invalid_request", "limit must be a whole number from 1 to 100.", "limit") + start = 0 + cursor = query.get("cursor") + if cursor: + if not cursor.startswith("cur_") or not cursor[4:].isdigit(): + raise ApiError(400, "invalid_cursor", "That cursor is not valid. Use next_cursor exactly as the last response returned it.", "cursor") + start = int(cursor[4:]) + chunk = items[start:start + limit] + more = start + limit < len(items) + return {"data": chunk, "has_more": more, "next_cursor": f"cur_{start + limit}" if more else None} + + +def refresh_status(loan): + if loan["status"] == "active" and date.fromisoformat(loan["due_on"]) < TODAY: + loan["status"] = "overdue" + return loan + + +def get_book(book_id): + book = STATE["books"].get(book_id) + if not book: + raise ApiError(404, "book_not_found", f"No book has the id {book_id}.", "book_id") + return book + + +def get_member(member_id): + member = STATE["members"].get(member_id) + if not member: + raise ApiError(404, "member_not_found", f"No member has the id {member_id}.", "member_id") + return dict(member, open_loans=sum(1 for l in STATE["loans"].values() + if l["member_id"] == member_id and l["status"] != "returned")) + + +def get_loan(loan_id): + loan = STATE["loans"].get(loan_id) + if not loan: + raise ApiError(404, "loan_not_found", f"No loan has the id {loan_id}.", "loan_id") + return refresh_status(loan) + + +def check_lend_body(body): + for field in ("member_id", "book_id"): + if field not in body or body[field] in (None, ""): + raise ApiError(400, "invalid_request", f"{field} is required.", field) + if not isinstance(body[field], str): + raise ApiError(400, "invalid_request", f"{field} must be a string.", field) + + +def parse_json_object(raw): + if not raw: + return {} + try: + body = json.loads(raw) + except json.JSONDecodeError: + raise ApiError(400, "invalid_json", "The request body is not valid JSON.") + if not isinstance(body, dict): + raise ApiError(400, "invalid_json", "The request body must be a JSON object.") + return body + + +def create_loan(body): + member = get_member(body["member_id"]) + book = get_book(body["book_id"]) + if member["card_status"] == "blocked": + dollars = member["fines_cents"] / 100 + raise ApiError(403, "member_blocked", + f"{member['name']} owes ${dollars:,.2f} in fines. A card with more than $10.00 in fines is blocked.", "member_id") + if book["copies_available"] < 1: + raise ApiError(409, "book_unavailable", f"All copies of \"{book['title']}\" are on loan.", "book_id") + book["copies_available"] -= 1 + loan_id = f"loan_{STATE['next_loan']:04d}" + STATE["next_loan"] += 1 + loan = {"id": loan_id, "member_id": member["id"], "book_id": book["id"], "status": "active", + "borrowed_on": TODAY.isoformat(), "due_on": (TODAY + timedelta(days=LOAN_DAYS)).isoformat(), + "returned_on": None, "renewals": 0} + STATE["loans"][loan_id] = loan + return loan + + +def renew_loan(loan): + if loan["status"] == "returned": + raise ApiError(409, "loan_not_active", f"{loan['id']} was already returned on {loan['returned_on']}.", "loan_id") + if loan["status"] == "overdue": + raise ApiError(409, "loan_overdue", f"{loan['id']} is overdue. Return the book first. An overdue loan cannot be renewed.", "loan_id") + if loan["renewals"] >= MAX_RENEWALS: + raise ApiError(409, "renewal_limit_reached", f"{loan['id']} has used all {MAX_RENEWALS} renewals.", "loan_id") + loan["renewals"] += 1 + loan["due_on"] = (date.fromisoformat(loan["due_on"]) + timedelta(days=RENEW_DAYS)).isoformat() + return loan + + +def return_loan(loan): + if loan["status"] == "returned": + raise ApiError(409, "loan_not_active", f"{loan['id']} was already returned on {loan['returned_on']}.", "loan_id") + loan["status"] = "returned" + loan["returned_on"] = TODAY.isoformat() + STATE["books"][loan["book_id"]]["copies_available"] += 1 + return loan + + +class Handler(BaseHTTPRequestHandler): + server_version = "HarborSandbox/1.0" + + def log_message(self, *args): + pass + + def send(self, status, body, extra=None): + payload = json.dumps(body, indent=2).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.send_header("X-Request-Id", self.request_id) + self.send_header("X-Librarian", "Marian") + extra = dict(extra or {}) + self.send_header("X-RateLimit-Limit", "60") + self.send_header("X-RateLimit-Remaining", extra.pop("X-RateLimit-Remaining", "59")) + for k, v in extra.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(payload) + + def fail(self, e, extra=None): + err = {"status": e.status, "code": e.code, "message": e.message} + if e.param: + err["param"] = e.param + err["doc_url"] = DOCS + e.code + err["request_id"] = self.request_id + self.send(e.status, {"error": err}, extra) + + def handle_any(self, method): + self.request_id = f"req_{STATE['next_request']:06d}" + STATE["next_request"] += 1 + path, _, qs = self.path.partition("?") + query = dict(p.split("=", 1) if "=" in p else (p, "") for p in qs.split("&") if p) + try: + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + + if path == "/v1/teapot": # Easter egg: see docs/easter-eggs.md. Needs no key. + if method != "GET": + raise ApiError(405, "method_not_allowed", f"{method} is not allowed at {path}.") + raise ApiError(418, "teapot", "I'm a teapot, short and stout. The library does not brew coffee.") + + auth = self.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise ApiError(401, "unauthorized", "Send your API key as: Authorization: Bearer YOUR_KEY.") + if auth[7:] != API_KEY: + raise ApiError(401, "invalid_api_key", "That API key is not recognized.") + + if self.headers.get("X-Sandbox-Force") == "rate_limited": # Easter egg: the 429 is "shush" + raise_rate = ApiError(429, "shush", "Shh. This is a library. Wait a moment, then try again.") + self.fail(raise_rate, {"Retry-After": "3", "X-RateLimit-Remaining": "0"}) + return + + parts = [p for p in path.split("/") if p] + if len(parts) < 2 or parts[0] != "v1": + raise ApiError(404, "route_not_found", f"There is no endpoint at {path}.") + res = parts[1] + rid = parts[2] if len(parts) > 2 else None + action = parts[3] if len(parts) > 3 else None + + if res == "books" and method == "GET" and not rid: + items = list(STATE["books"].values()) + q = query.get("q", "").replace("%20", " ").replace("+", " ").lower() + if q: + items = [b for b in items if q in b["title"].lower() or q in b["author"].lower()] + if "available" in query and query["available"] not in ("true", "false"): + raise ApiError(400, "invalid_request", "available must be true or false.", "available") + if query.get("available") == "true": + items = [b for b in items if b["copies_available"] > 0] + return self.send(200, page(items, query)) + if res == "books" and method == "GET" and rid and not action: + return self.send(200, get_book(rid)) + if res == "members" and method == "GET" and rid and not action: + return self.send(200, get_member(rid)) + if res == "loans" and method == "GET" and not rid: + items = [refresh_status(l) for l in STATE["loans"].values()] + if "member_id" in query: + items = [l for l in items if l["member_id"] == query["member_id"]] + if "status" in query: + if query["status"] not in ("active", "overdue", "returned"): + raise ApiError(400, "invalid_request", "status must be active, overdue, or returned.", "status") + items = [l for l in items if l["status"] == query["status"]] + return self.send(200, page(items, query)) + if res == "loans" and method == "GET" and rid and not action: + return self.send(200, get_loan(rid)) + if res == "loans" and method == "POST" and not rid: + body = parse_json_object(raw) + check_lend_body(body) + key = self.headers.get("Idempotency-Key") + if key is not None and not 1 <= len(key) <= 64: + raise ApiError(400, "invalid_request", "Idempotency-Key must be 1 to 64 characters.", "Idempotency-Key") + if key and key in STATE["idempotency"]: + first_body, saved = STATE["idempotency"][key] + if first_body != body: + raise ApiError(409, "idempotency_key_reused", + "That Idempotency-Key was already used with a different request. Use a new key.", "Idempotency-Key") + return self.send(201, saved, {"Idempotent-Replay": "true", "Location": f"/v1/loans/{saved['id']}"}) + loan = create_loan(body) + if key: # only a successful lend is stored; a failed request can be retried with the same key + STATE["idempotency"][key] = (copy.deepcopy(body), copy.deepcopy(loan)) + return self.send(201, loan, {"Location": f"/v1/loans/{loan['id']}"}) + if res == "loans" and method == "POST" and rid and action == "renew": + return self.send(200, renew_loan(get_loan(rid))) + if res == "loans" and method == "POST" and rid and action == "return": + return self.send(200, return_loan(get_loan(rid))) + if res in ("books", "members", "loans", "teapot"): + raise ApiError(405, "method_not_allowed", f"{method} is not allowed at {path}.") + raise ApiError(404, "route_not_found", f"There is no endpoint at {path}.") + except ApiError as e: + self.fail(e) + + def do_GET(self): + self.handle_any("GET") + + def do_POST(self): + self.handle_any("POST") + + def do_HEAD(self): + self.handle_any("HEAD") + + def do_OPTIONS(self): + self.handle_any("OPTIONS") + + def do_PUT(self): + self.handle_any("PUT") + + def do_PATCH(self): + self.handle_any("PATCH") + + def do_DELETE(self): + self.handle_any("DELETE") + + +def serve(port=8080): + return ThreadingHTTPServer(("127.0.0.1", port), Handler) + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 + print(f"Harbor Library sandbox on http://127.0.0.1:{port} (API key: {API_KEY})", flush=True) + serve(port).serve_forever() diff --git a/api-reference/harbor-library/openapi.yaml b/api-reference/harbor-library/openapi.yaml new file mode 100644 index 0000000..fcf2262 --- /dev/null +++ b/api-reference/harbor-library/openapi.yaml @@ -0,0 +1,339 @@ +openapi: 3.1.0 +info: + title: Harbor Library Lending API + version: 1.0.0 + summary: Look up books and members, and lend, renew and return books. + description: | + A fictional API made for this writing sample. Harbor Library does not exist. + The sandbox in `mock/server.py` answers every request in the documentation. + + Any method a path does not support returns `405` with the usual error body, and any + unknown path returns `404`. Both are described in `docs/errors.md`. +servers: + - url: https://api.harborlibrary.example/v1 + description: Production (fictional) + - url: http://127.0.0.1:8080/v1 + description: Local sandbox +security: + - bearerAuth: [] +tags: + - name: Books + - name: Members + - name: Loans + - name: Fun +paths: + /books: + get: + tags: [Books] + operationId: listBooks + summary: List books + description: Returns books, 20 per page by default. Filter by search text or by availability. + parameters: + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/Cursor'} + - name: q + in: query + description: Text to find in the title or the author. Not case sensitive. + schema: {type: string} + example: earthsea + - name: available + in: query + description: '`true` returns only books with a copy on the shelf. `false` or leaving it out returns all books. Any other value returns 400.' + schema: {type: boolean} + responses: + '200': + description: A page of books. + content: + application/json: + schema: {$ref: '#/components/schemas/BookPage'} + '400': {$ref: '#/components/responses/Error'} + '401': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + /books/{book_id}: + get: + tags: [Books] + operationId: getBook + summary: Get a book + parameters: + - {$ref: '#/components/parameters/BookId'} + responses: + '200': + description: The book. + content: + application/json: + schema: {$ref: '#/components/schemas/Book'} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + /members/{member_id}: + get: + tags: [Members] + operationId: getMember + summary: Get a member + parameters: + - {$ref: '#/components/parameters/MemberId'} + responses: + '200': + description: The member. + content: + application/json: + schema: {$ref: '#/components/schemas/Member'} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + /loans: + get: + tags: [Loans] + operationId: listLoans + summary: List loans + parameters: + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/Cursor'} + - name: member_id + in: query + description: Return only this member's loans. + schema: {type: string} + example: mem_ada + - name: status + in: query + description: Return only loans in this state. + schema: {$ref: '#/components/schemas/LoanStatus'} + responses: + '200': + description: A page of loans. + content: + application/json: + schema: {$ref: '#/components/schemas/LoanPage'} + '400': {$ref: '#/components/responses/Error'} + '401': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + post: + tags: [Loans] + operationId: createLoan + summary: Lend a book + description: | + Lends one copy of a book to a member for 21 days. Send an `Idempotency-Key` + header so a retry after a network failure cannot lend the book twice. + parameters: + - name: Idempotency-Key + in: header + description: A unique string, 1 to 64 characters. The same key with the same body returns the first loan again. The same key with a different body returns 409 `idempotency_key_reused`. Only successful lends are stored. + schema: {type: string, minLength: 1, maxLength: 64} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [member_id, book_id] + properties: + member_id: {type: string, example: mem_grace} + book_id: {type: string, example: bk_003} + responses: + '201': + description: The new loan. A repeat of an earlier request with the same `Idempotency-Key` returns the first loan again, with `Idempotent-Replay` set. + headers: + Location: + description: Path of the new loan. + schema: {type: string} + Idempotent-Replay: + description: '`true` when this is the first response replayed for a repeated `Idempotency-Key`.' + schema: {type: string, enum: ['true']} + content: + application/json: + schema: {$ref: '#/components/schemas/Loan'} + '400': {$ref: '#/components/responses/Error'} + '401': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} + '409': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + /loans/{loan_id}: + get: + tags: [Loans] + operationId: getLoan + summary: Get a loan + parameters: + - {$ref: '#/components/parameters/LoanId'} + responses: + '200': + description: The loan. + content: + application/json: + schema: {$ref: '#/components/schemas/Loan'} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + /loans/{loan_id}/renew: + post: + tags: [Loans] + operationId: renewLoan + summary: Renew a loan + description: Adds 14 days to the due date. A loan can be renewed twice, and not once it is overdue. + parameters: + - {$ref: '#/components/parameters/LoanId'} + responses: + '200': + description: The loan with its new due date. + content: + application/json: + schema: {$ref: '#/components/schemas/Loan'} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} + '409': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + /loans/{loan_id}/return: + post: + tags: [Loans] + operationId: returnLoan + summary: Return a book + parameters: + - {$ref: '#/components/parameters/LoanId'} + responses: + '200': + description: The loan, now returned. + content: + application/json: + schema: {$ref: '#/components/schemas/Loan'} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} + '409': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + /teapot: + get: + tags: [Fun] + operationId: getTeapot + summary: Ask the library for coffee + description: An Easter egg. Needs no API key. Always returns 418. See docs/easter-eggs.md. + security: [] + responses: + '418': {$ref: '#/components/responses/Error'} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: 'Send your key as `Authorization: Bearer YOUR_KEY`. The sandbox key is `hl_test_sandbox`.' + parameters: + Limit: + name: limit + in: query + description: How many items to return, from 1 to 100. + schema: {type: integer, minimum: 1, maximum: 100, default: 20} + Cursor: + name: cursor + in: query + description: Pass the `next_cursor` from the last page to get the next one. + schema: {type: string} + BookId: + name: book_id + in: path + required: true + schema: {type: string} + example: bk_042 + MemberId: + name: member_id + in: path + required: true + schema: {type: string} + example: mem_ada + LoanId: + name: loan_id + in: path + required: true + schema: {type: string} + example: loan_0002 + responses: + Error: + description: An error. The `code` field is stable and safe to branch on. See docs/errors.md. + headers: + X-Request-Id: + description: Quote this when you contact support. Also in the body as `request_id`. + schema: {type: string} + X-RateLimit-Limit: + description: Requests allowed per minute. + schema: {type: integer} + X-RateLimit-Remaining: + description: Requests left in this minute. + schema: {type: integer} + Retry-After: + description: Only on 429. Seconds to wait before retrying. + schema: {type: integer} + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + schemas: + LoanStatus: + type: string + enum: [active, overdue, returned] + Book: + type: object + required: [id, title, author, copies_total, copies_available] + properties: + id: {type: string, example: bk_042} + title: {type: string, example: The Hitchhiker's Guide to the Galaxy} + author: {type: string, example: Douglas Adams} + copies_total: {type: integer, example: 42} + copies_available: {type: integer, example: 42} + Member: + type: object + required: [id, name, card_status, fines_cents, open_loans] + properties: + id: {type: string, example: mem_ada} + name: {type: string, example: Ada Lovelace} + card_status: + type: string + enum: [active, blocked] + description: '`blocked` when fines are above $10.00. A blocked member cannot borrow.' + fines_cents: {type: integer, description: Fines owed, in cents., example: 0} + open_loans: {type: integer, description: Loans that are active or overdue., example: 1} + Loan: + type: object + required: [id, member_id, book_id, status, borrowed_on, due_on, returned_on, renewals] + properties: + id: {type: string, example: loan_0002} + member_id: {type: string, example: mem_ada} + book_id: {type: string, example: bk_002} + status: {$ref: '#/components/schemas/LoanStatus'} + borrowed_on: {type: string, format: date, example: '2026-10-12'} + due_on: {type: string, format: date, example: '2026-11-02'} + returned_on: {type: [string, 'null'], format: date} + renewals: {type: integer, minimum: 0, maximum: 2} + Page: + type: object + required: [data, has_more, next_cursor] + properties: + has_more: {type: boolean} + next_cursor: + type: [string, 'null'] + description: Pass this as `cursor` to get the next page. `null` on the last page. + BookPage: + allOf: + - {$ref: '#/components/schemas/Page'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/Book'} + LoanPage: + allOf: + - {$ref: '#/components/schemas/Page'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/Loan'} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [status, code, message, doc_url, request_id] + properties: + status: {type: integer, example: 409} + code: {type: string, example: book_unavailable} + message: {type: string, description: Written for a person. Do not match on it.} + param: {type: string, description: The input that caused the error, when there is one.} + doc_url: {type: string, format: uri} + request_id: {type: string, description: Quote this when you contact support., example: req_000123} diff --git a/api-reference/harbor-library/tests/run_examples.py b/api-reference/harbor-library/tests/run_examples.py new file mode 100644 index 0000000..9cf6844 --- /dev/null +++ b/api-reference/harbor-library/tests/run_examples.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Run every curl example in the Harbor Library docs against the sandbox. + + python3 api-reference/harbor-library/tests/run_examples.py # check + python3 api-reference/harbor-library/tests/run_examples.py --update # rewrite the saved responses + +A bash block that starts with `curl` and is followed by a json block is one example. +The json block is the response the docs promise. The check runs the request against +the sandbox and fails if the answer differs, or if the status is not one the OpenAPI +file lists for that route. Each page runs against a fresh sandbox, top to bottom, so +a page may rely on what an earlier example on the same page did. + +It also checks that every error code in the sandbox has its own heading in docs/errors.md, and that every link to a section of that page lands on a real heading. +""" +import json +import pathlib +import re +import shlex +import subprocess +import sys +import threading + +import yaml + +HERE = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(HERE / "mock")) +import server # noqa: E402 + +BASE = "http://127.0.0.1:8080" +PAGES = ["docs/quickstart.md", "docs/authentication.md", "docs/pagination.md", "docs/idempotency.md", + "docs/reference/books.md", "docs/reference/members.md", "docs/reference/loans.md", + "docs/errors.md", "docs/rate-limits.md", "docs/easter-eggs.md"] +FENCE = re.compile(r"^```(\w*)\s*$") + + +def blocks(lines): + """Yield (lang, start, end, body) for every fenced block. start/end are line indexes of the fences.""" + i = 0 + while i < len(lines): + m = FENCE.match(lines[i]) + if m and m.group(1): + j = i + 1 + while j < len(lines) and not FENCE.match(lines[j]): + j += 1 + yield m.group(1), i, j, "\n".join(lines[i + 1:j]) + i = j + 1 + else: + i += 1 + + +def run_curl(cmd, port): + cmd = cmd.replace("\\\n", " ").replace(BASE, f"http://127.0.0.1:{port}") + args = shlex.split(cmd) + out = subprocess.run(args[:1] + ["-s", "-w", "\n%{http_code}"] + args[1:], capture_output=True, text=True, timeout=20) + body, _, status = out.stdout.rpartition("\n") + return int(status), json.loads(body) + + +def mask(obj): + if isinstance(obj, dict): + return {k: ("" if k == "request_id" else mask(v)) for k, v in obj.items()} + if isinstance(obj, list): + return [mask(v) for v in obj] + return obj + + +def route_statuses(spec): + table = [] + for path, ops in spec["paths"].items(): + rx = re.compile("^" + re.sub(r"\{[^}]+\}", "[^/]+", path) + "$") + for method, op in ops.items(): + table.append((method.upper(), rx, {int(c) for c in op["responses"]})) + return table + + +def main(update=False): + spec = yaml.safe_load((HERE / "openapi.yaml").read_text()) + routes = route_statuses(spec) + httpd = server.serve(0) + port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + problems, count = [], 0 + + for page in PAGES: + path = HERE / page + lines = path.read_text(encoding="utf-8").split("\n") + server.STATE = server.fresh_state() + found = list(blocks(lines)) + edits = [] + for k, (lang, s, e, body) in enumerate(found): + if lang != "bash" or not body.lstrip().startswith("curl"): + continue + if k + 1 >= len(found) or found[k + 1][0] != "json": + problems.append(f"{page}:{s + 1}: curl example has no json response block after it") + continue + count += 1 + status, actual = run_curl(body, port) + _, js, je, saved = found[k + 1] + method = "POST" if re.search(r"-X\s+POST|\s-d\s|--data", body) else "GET" + url_path = re.search(re.escape(BASE) + r"(/[^\s?'\"]*)", body).group(1).replace("/v1", "", 1) + ok_routes = [r for r in routes if r[0] == method and r[1].match(url_path)] + if ok_routes and status not in ok_routes[0][2]: + problems.append(f"{page}:{s + 1}: status {status} is not listed in openapi.yaml for {method} {url_path}") + if update: + edits.append((js, je, json.dumps(actual, indent=2))) + elif mask(json.loads(saved)) != mask(actual): + problems.append(f"{page}:{s + 1}: the response in the docs does not match the sandbox (status {status})") + for js, je, text in reversed(edits): + lines[js + 1:je] = text.split("\n") + if update: + path.write_text("\n".join(lines), encoding="utf-8") + + errors_doc = (HERE / "docs/errors.md").read_text(encoding="utf-8") + src = (HERE / "mock/server.py").read_text(encoding="utf-8") + codes = set(re.findall(r'ApiError\(\d+,\s*"([a-z_]+)"', src)) | {"shush"} + for code in sorted(codes): + if f"`{code}`" not in errors_doc: + problems.append(f"docs/errors.md: the sandbox can return `{code}` but the page never names it") + + # Every link to a section of the errors page must land on a real heading. + def slug(h): + return re.sub(r"[^a-z0-9_ -]", "", h.lower()).strip().replace(" ", "-") + anchors = {slug(h) for h in re.findall(r"(?m)^#{1,6} (.+)$", errors_doc)} + for page in PAGES + ["README.md"]: + for m in re.finditer(r"errors\.md#([A-Za-z0-9_-]+)|\]\(#([A-Za-z0-9_-]+)\)", (HERE / page).read_text(encoding="utf-8")): + target = m.group(1) or (m.group(2) if page == "docs/errors.md" else None) + if target and target not in anchors: + problems.append(f"{page}: links to errors.md#{target}, which is not a heading on that page") + for code in sorted(codes): + if code not in anchors: + problems.append(f"docs/errors.md: no heading for `{code}`, so the doc_url the API sends lands nowhere") + + httpd.shutdown() + if problems: + print(f"run_examples: {len(problems)} problem(s)") + for p in problems: + print(" " + p) + return 1 + print(f"run_examples: clean ({count} examples run against the sandbox)") + return 0 + + +if __name__ == "__main__": + sys.exit(main("--update" in sys.argv)) diff --git a/h8082.log b/h8082.log new file mode 100644 index 0000000..f0546fe --- /dev/null +++ b/h8082.log @@ -0,0 +1 @@ +Harbor Library sandbox on http://127.0.0.1:8082 (API key: hl_test_sandbox)