diff --git a/.env.example b/.env.example index 815dba2..a710cff 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # TaskBox local configuration (safe defaults for the SQLite lab) -APP_ENV=development +# `development` permits the placeholder secrets below for disposable local labs only. +TASKBOX_ENV=development TASKBOX_DATABASE_URL=sqlite:///./taskbox.db TASKBOX_JWT_SECRET=change-me-in-development TASKBOX_JWT_EXPIRES=3600 diff --git a/.postman/resources.yaml b/.postman/resources.yaml new file mode 100644 index 0000000..44eac86 --- /dev/null +++ b/.postman/resources.yaml @@ -0,0 +1,8 @@ +# Use this workspace to collaborate +workspace: + id: 7e09ac8a-2f0a-42c4-9f4e-81503aaca133 + +localResources: + specs: + - ../contracts/taskbox.openapi.json + - ../course/labs/01-http-api-design/solution/openapi.yaml diff --git a/Dockerfile b/Dockerfile index e841866..5533589 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,19 @@ FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy + UV_LINK_MODE=copy \ + TASKBOX_ENV=production \ + TASKBOX_DATABASE_URL=sqlite:////data/taskbox.db WORKDIR /app RUN pip install --no-cache-dir uv +RUN groupadd --system taskbox +RUN useradd --system --gid taskbox --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin taskbox +RUN install --directory --owner=taskbox --group=taskbox /data COPY pyproject.toml uv.lock README.md LICENSE ./ COPY src ./src -COPY migrations ./migrations -COPY alembic.ini ./ RUN uv sync --frozen --no-dev EXPOSE 8000 +USER taskbox +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/app/.venv/bin/python", "-c", "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/healthz', timeout=3).read()"] CMD ["/app/.venv/bin/uvicorn", "taskbox.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index a5d04f9..e52c46a 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ This repository is a beginner-to-production course for designing, building, testing, and operating APIs. -The new course uses Python, FastAPI, SQLite, and a TaskBox capstone. It includes runnable labs, generated OpenAPI contracts, authentication, webhooks, observability, and deployment exercises. Historical material is preserved under [`legacy/`](legacy/). +The new course uses Python, FastAPI, SQLite, and a TaskBox capstone. It includes runnable labs, generated OpenAPI contracts, authentication, webhooks, observability, and deployment exercises. ## Start here -1. Install Python 3.13+, Node 24 LTS, and `uv`. +1. Install Postman, Python 3.13+, Node 24 LTS, and `uv`. 2. Run `uv sync --all-groups --frozen`. 3. Start TaskBox with `uv run uvicorn taskbox.main:app --reload`. 4. Open the API docs at `http://127.0.0.1:8000/docs`. -5. Follow the 40-hour sequence in [`course/course-map.yml`](course/course-map.yml). -6. Start the course site with `cd site && npm ci && npm run dev`. +5. Complete required [Prerequisite Lab 00: Postman foundations](course/labs/00-postman-prerequisite/README.md). +6. Follow the 43-hour sequence in [`course/course-map.yml`](course/course-map.yml), beginning Lab 01 only after the prerequisite. +7. Start the course site with `cd site && npm ci && npm run dev`. Local site routes start at `http://localhost:4321/`. The GitHub Pages build uses `/API/`, so the deployed setup page is `https://ialimustufa.github.io/API/setup/`. @@ -35,16 +36,23 @@ The root Compose stack deploys the SQLite-first TaskBox API with a persistent Docker volume: ```bash +export TASKBOX_JWT_SECRET="$(openssl rand -hex 32)" +export TASKBOX_WEBHOOK_SECRET="$(openssl rand -hex 32)" docker compose up --build curl http://127.0.0.1:8000/healthz curl http://127.0.0.1:8000/readyz ``` -Set `TASKBOX_JWT_SECRET` and `TASKBOX_WEBHOOK_SECRET` to long random values in -`.env` before exposing the API. The PostgreSQL transition is a separate required -exercise in [`course/labs/07-operations`](course/labs/07-operations/). - +Compose refuses to start without both secrets, and the production image rejects +the course placeholder values. Store long random values in `.env` instead of +exporting them when that better fits your local workflow. The PostgreSQL +transition is a separate required exercise in +[`course/labs/07-operations`](course/labs/07-operations/). ## License Authored course and application code is MIT licensed. Historical third-party material retains its original provenance; see [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). + +## Legacy + +The original course materials are preserved under [`legacy/`](legacy/). diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 61de057..0000000 --- a/alembic.ini +++ /dev/null @@ -1,29 +0,0 @@ -[alembic] -script_location = migrations -prepend_sys_path = . -sqlalchemy.url = sqlite:///./taskbox.db - -[loggers] -keys = root,sqlalchemy,alembic -[handlers] -keys = console -[formatters] -keys = generic -[logger_root] -level = WARN -handlers = console -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine -[logger_alembic] -level = INFO -handlers = -qualname = alembic -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/compose.yaml b/compose.yaml index 508aa36..c587f91 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,9 +2,10 @@ services: app: build: . environment: + TASKBOX_ENV: production TASKBOX_DATABASE_URL: sqlite:////data/taskbox.db - TASKBOX_JWT_SECRET: ${TASKBOX_JWT_SECRET:-change-me-in-development} - TASKBOX_WEBHOOK_SECRET: ${TASKBOX_WEBHOOK_SECRET:-change-me-in-development} + TASKBOX_JWT_SECRET: "${TASKBOX_JWT_SECRET:?Set TASKBOX_JWT_SECRET to a non-default secret}" + TASKBOX_WEBHOOK_SECRET: "${TASKBOX_WEBHOOK_SECRET:?Set TASKBOX_WEBHOOK_SECRET to a non-default secret}" ports: - "8000:8000" volumes: diff --git a/contracts/taskbox.openapi.json b/contracts/taskbox.openapi.json index c1da0d1..d6d7226 100644 --- a/contracts/taskbox.openapi.json +++ b/contracts/taskbox.openapi.json @@ -7,6 +7,7 @@ }, "servers": [{"url": "http://localhost:8000", "description": "Local development"}], "tags": [ + {"name": "Health"}, {"name": "Auth"}, {"name": "Projects"}, {"name": "Tasks"}, @@ -14,7 +15,7 @@ ], "paths": { "/healthz": { - "get": {"operationId": "healthCheck", "tags": ["Auth"], "responses": {"200": {"description": "Service is healthy", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Health"}}}}}} + "get": {"operationId": "healthCheck", "tags": ["Health"], "responses": {"200": {"description": "Service is healthy", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Health"}}}}}} }, "/api/v1/auth/register": { "post": { @@ -200,8 +201,8 @@ "Task": {"type": "object", "required": ["id", "project_id", "created_by", "title", "status", "priority", "created_at", "updated_at"], "properties": {"id": {"type": "string", "format": "uuid"}, "project_id": {"type": "string", "format": "uuid"}, "created_by": {"type": "string", "format": "uuid"}, "assignee_id": {"type": ["string", "null"], "format": "uuid"}, "title": {"type": "string", "maxLength": 240}, "description": {"type": ["string", "null"], "maxLength": 10000}, "status": {"$ref": "#/components/schemas/TaskStatus"}, "priority": {"type": "integer", "minimum": 0, "maximum": 4}, "due_at": {"type": ["string", "null"], "format": "date-time"}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}}}, "TaskCreate": {"type": "object", "required": ["title"], "properties": {"title": {"type": "string", "minLength": 1, "maxLength": 240}, "description": {"type": ["string", "null"], "maxLength": 10000}, "status": {"$ref": "#/components/schemas/TaskStatus"}, "priority": {"type": "integer", "minimum": 0, "maximum": 4}, "assignee_id": {"type": ["string", "null"], "format": "uuid"}, "due_at": {"type": ["string", "null"], "format": "date-time"}}}, "TaskUpdate": {"type": "object", "minProperties": 1, "properties": {"title": {"type": "string", "minLength": 1, "maxLength": 240}, "description": {"type": ["string", "null"], "maxLength": 10000}, "status": {"$ref": "#/components/schemas/TaskStatus"}, "priority": {"type": "integer", "minimum": 0, "maximum": 4}, "assignee_id": {"type": ["string", "null"], "format": "uuid"}, "due_at": {"type": ["string", "null"], "format": "date-time"}}}, - "WebhookImportRequest": {"type": "object", "required": ["project_id", "tasks"], "properties": {"project_id": {"type": "string", "format": "uuid"}, "tasks": {"type": "array", "minItems": 1, "items": {"$ref": "#/components/schemas/TaskCreate"}}}}, - "WebhookImportResponse": {"type": "object", "required": ["event_id", "imported", "duplicate"], "properties": {"event_id": {"type": "string"}, "imported": {"type": "integer", "minimum": 0}, "duplicate": {"type": "boolean"}}}, + "WebhookImportRequest": {"type": "object", "required": ["project_id", "tasks"], "properties": {"project_id": {"type": "string", "format": "uuid"}, "actor_id": {"type": ["string", "null"], "format": "uuid", "description": "Optional actor when no Bearer token is supplied; see the threat-model guidance."}, "tasks": {"type": "array", "minItems": 1, "items": {"$ref": "#/components/schemas/TaskCreate"}}}}, + "WebhookImportResponse": {"type": "object", "required": ["event_id", "imported"], "properties": {"event_id": {"type": "string"}, "imported": {"type": "integer", "minimum": 0}}}, "PageInfo": {"type": "object", "required": ["next_cursor"], "properties": {"next_cursor": {"type": ["string", "null"]}}}, "ProjectPage": {"type": "object", "required": ["items", "next_cursor"], "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/Project"}}, "next_cursor": {"type": ["string", "null"]}}}, "MembershipPage": {"type": "object", "required": ["items", "next_cursor"], "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/Membership"}}, "next_cursor": {"type": ["string", "null"]}}}, diff --git a/course/course-map.yml b/course/course-map.yml index b12b4fb..22cd7ad 100644 --- a/course/course-map.yml +++ b/course/course-map.yml @@ -1,9 +1,17 @@ title: API Engineering with TaskBox version: 1 baseline: python-3.13 -duration_hours: 40 +duration_hours: 43 format: self-paced modules: + - id: postman-prerequisite + title: Postman foundations with TaskBox + hours: 3 + prerequisite: true + labs: [00-postman-prerequisite] + outcomes: + - Use the local TaskBox collection, variables, authorization, scripts, examples, and documentation safely + - Run and troubleshoot the dependency-ordered TaskBox workflow before beginning Lab 01 - id: http-api-design title: HTTP and API design hours: 4 @@ -82,8 +90,9 @@ modules: - Integrate the course capabilities into one production-minded API - Document migration, rollback, threat-model, and smoke-test plans course_completion: - required_hours: 40 + required_hours: 43 required_labs: + - 00-postman-prerequisite - 01-http-api-design - 02-fastapi-basics - 03-crud diff --git a/course/examples/webhooks/sign.py b/course/examples/webhooks/sign.py index d9605bf..33433e7 100644 --- a/course/examples/webhooks/sign.py +++ b/course/examples/webhooks/sign.py @@ -1,3 +1,5 @@ +"""Signer for the timestamped Lab 10 receiver, not the TaskBox reference API.""" + import hashlib import hmac import json diff --git a/course/examples/webhooks/sign_taskbox.py b/course/examples/webhooks/sign_taskbox.py new file mode 100644 index 0000000..f0718ba --- /dev/null +++ b/course/examples/webhooks/sign_taskbox.py @@ -0,0 +1,27 @@ +"""Create headers and a body for TaskBox's raw-body HMAC webhook endpoint. + +Set TASKBOX_PROJECT_ID and TASKBOX_ACTOR_ID from a TaskBox project before +sending the printed body to POST /api/v1/webhooks/tasks/import. +""" + +import hashlib +import hmac +import json +import os +import uuid + +secret = os.getenv("TASKBOX_WEBHOOK_SECRET", "dev-webhook-secret").encode() +event_id = os.getenv("TASKBOX_WEBHOOK_EVENT_ID", f"evt-{uuid.uuid4()}") +body = json.dumps( + { + "project_id": os.environ["TASKBOX_PROJECT_ID"], + "actor_id": os.environ["TASKBOX_ACTOR_ID"], + "tasks": [{"title": "Imported vendor task", "priority": 2}], + }, + separators=(",", ":"), +).encode() +signature = hmac.new(secret, body, hashlib.sha256).hexdigest() + +print(f"X-Webhook-Event-ID: {event_id}") +print(f"X-Webhook-Signature: sha256={signature}") +print(body.decode()) diff --git a/course/labs/00-postman-prerequisite/README.md b/course/labs/00-postman-prerequisite/README.md new file mode 100644 index 0000000..52d6f7c --- /dev/null +++ b/course/labs/00-postman-prerequisite/README.md @@ -0,0 +1,228 @@ +# Prerequisite Lab 00: Postman foundations with TaskBox + +This required prerequisite establishes the Postman workflow used throughout **API Engineering with TaskBox**. Complete it before Lab 01. You will work with the repository's existing file-backed `TaskBox API` collection rather than creating a replacement or changing request behavior. + +**Estimated time:** 3 hours + +## Learning objectives + +By the end, you can: + +- navigate the local workspace, collection, folders, requests, and saved examples; +- compose and send HTTP requests, then inspect status, headers, body, timing, and test results; +- explain variable resolution and choose an appropriate scope; +- keep secrets out of shared collection files and use local session/environment values; +- recognize request-level and inherited Bearer authorization; +- read before-request and after-response scripts without weakening their assertions; +- distinguish saved examples from live responses; +- run the TaskBox workflow in dependency order and diagnose failures; +- use collection and request descriptions as course documentation. + +## Prerequisites + +- Clone/open this repository in Postman with the repository root as the local workspace folder. +- Install Python 3.13+ and `uv`; run `uv sync --all-groups --frozen` from the repository root. +- Have the Postman desktop app available. Node 24 LTS is needed only to preview the course site. +- Use only a disposable local TaskBox database and non-production identities. The supplied owner example is `Ali Mustufa` / `ali.shaikh@example.com`. + +No prior request scripting experience is required. Basic familiarity with HTTP methods and JSON is helpful. + +## 1. Start TaskBox and verify the target + +From the repository root: + +```bash +uv sync --all-groups --frozen +uv run uvicorn taskbox.main:app --reload +``` + +Keep that terminal open. TaskBox listens at `http://127.0.0.1:8000`; generated API documentation is at `http://127.0.0.1:8000/docs`. + +In Postman, open **TaskBox API > Health > Health check** and select **Send**. Inspect all response areas: + +1. status is `200 OK`; +2. body is `{"status":"ok"}`; +3. `Content-Type` is JSON; +4. response timing is displayed; +5. the request and collection after-response tests pass. + +Repeat with **Liveness check** and **Readiness check**. Liveness checks the process; readiness also checks the configured database connection. + +**Expected outcome:** all three requests return 200 and their tests pass. A connection error means the local server is not reachable at the resolved `base_url`. + +## 2. Map the local workspace and collection + +The source of truth in this filesystem workspace is: + +```text +postman/collections/TaskBox API/ +├── .resources/definition.yaml +├── Health/ +├── Auth/ +├── Projects/ +├── Members/ +├── Tasks/ +└── Webhooks/ +``` + +Open these items in Postman and compare them with their files: + +- `.resources/definition.yaml` contains collection description, variables, and collection-level scripts. +- Each folder's `.resources/definition.yaml` contains folder documentation, order, and sometimes inherited authorization. +- `*.request.yaml` files contain methods, URLs, parameters, bodies, authorization overrides, scripts, and example-directory references. +- `.resources/.resources/examples/*.example.yaml` contains saved request/response examples. + +Do not put request files inside `.resources/`, and do not edit generated IDs. Local changes are real repository changes: review them before committing. + +**Exercise:** locate **Register owner**, **Create project**, and **Import tasks** in both the sidebar and filesystem. Identify their HTTP method, URL, body, scripts, and order value. + +## 3. Requests, variables, and scopes + +Open **Register owner**. Its URL is `{{base_url}}/api/v1/auth/register`, and its JSON body uses `owner_email`, `owner_password`, and `owner_display_name`. Hover or inspect each variable to confirm its resolved value before sending. + +The collection defines reusable defaults including: + +- configuration/input: `base_url`, owner/member identity fields, and `webhook_secret`; +- runtime state: `owner_id`, `member_id`, `owner_access_token`, `member_access_token`, `project_id`, `task_id`, `cursor`, and `webhook_event_id`. + +Use the narrowest practical scope: + +| Scope | Use in this course | +| --- | --- | +| Local/temporary | one-off experimentation that must not persist | +| Data/iteration | values supplied to one collection run | +| Environment | machine- or deployment-specific values such as a different base URL; secrets should be local/session-only | +| Collection | shared TaskBox defaults and workflow state used by these requests | +| Global | avoid for this course because it can silently affect unrelated collections | + +A narrower scope with the same name overrides a broader one. Before debugging a request, inspect the resolved value and its scope rather than assuming the collection default won. + +**Exercise:** create or select a local TaskBox development environment only if you need to override machine-specific values. Add `base_url=http://127.0.0.1:8000` as a local value, send **Health check**, then disable/remove the override and verify the collection value resolves again. Do not duplicate every collection variable into the environment. + +## 4. Secret hygiene and authorization + +The committed passwords and `dev-webhook-secret` are disposable development defaults, not production credentials. Never commit real passwords, JWTs, webhook secrets, API keys, or populated local environment exports. For non-local work, set sensitive values in an appropriate local/session value and keep the shared value empty or demonstrably non-secret. Configure long random `TASKBOX_JWT_SECRET` and `TASKBOX_WEBHOOK_SECRET` on the service before exposure. + +Open **Projects** and its folder documentation. The folder supplies Bearer auth using `{{owner_access_token}}`. Requests inherit that auth unless they override it. Compare: + +- **Register owner**: request-level `noauth`; +- **Get current owner**: request-level Bearer `{{owner_access_token}}`; +- **Projects** requests: folder-inherited owner Bearer token; +- **Member can list members**: request-level override with `{{member_access_token}}`. + +Do not paste a token into an `Authorization` header in a saved request. Let the auth configuration resolve the variable. + +**Exercise:** send **Get current owner** before token creation and observe the authentication failure. Then follow the owner bootstrap below and resend it successfully. + +## 5. Scripts, tests, and runtime state + +Scripts in this collection are existing executable documentation; do not remove or relax them to make a failure disappear. + +- Collection after-response tests check response time, JSON content type, and TaskBox problem details. +- Request after-response tests check request-specific status/body contracts. +- Capture scripts save IDs and tokens with `pm.collectionVariables.set(...)` for later requests. +- **Import tasks** has a before-request script that substitutes the exact raw body, computes HMAC-SHA256 using `webhook_secret`, and sets `webhook_signature`. + +Open **Register owner** and read its after-response script. It checks `201`, validates the public user shape, confirms credentials are absent, and captures `owner_id`. Open **Get owner token** and identify where it captures `owner_access_token`. + +Send in this order: + +1. **Register owner** +2. **Get owner token** +3. **Get current owner** + +Inspect test results after every send and inspect the updated collection values. If registration returns `409`, the persistent database already contains that email; change the disposable `owner_email` value or intentionally start with a fresh disposable database. Do not delete data you did not create for this lab. + +**Expected outcome:** `owner_id` and `owner_access_token` are populated locally, and **Get current owner** returns the registered identity. + +## 6. Saved examples versus live responses + +Open the saved examples attached to **Health check**, **Register owner**, and **Get owner token**. An example is static documentation/mock material: viewing it does not contact TaskBox, execute scripts, or update variables. A live response records what the running server returned now and runs the applicable tests. + +Compare the live duplicate-registration `409` response with the saved **409 Conflict** example. Check status, `application/problem+json`, and the stable problem fields. Example UUIDs, timestamps, and abbreviated JWTs are illustrative and must not be copied into workflow variables. + +**Exercise:** explain why a saved `201 Created` example can remain useful even when your current live registration correctly returns 409. + +## 7. Run the practical workflow + +Run individual requests first so you understand dependencies, then use the collection runner. Preserve folder/request order; later requests consume values captured earlier. + +Recommended sequence: + +1. Health: health, liveness, readiness. +2. Auth: register owner, owner token, current owner; register member, member token, current member. +3. Projects: create project before project reads/updates; its script captures `project_id`. +4. Members: add the captured member, promote to editor, and run the member authorization request. +5. Tasks: create before get/update/member-read; creation captures `task_id`; then verify editor creation. +6. Webhooks: set a fresh `webhook_event_id`, import once, and inspect the accepted response. Reusing the ID is intentionally a 409, but the saved request's success assertion expects the first 202 run. +7. Cleanup: delete tasks, remove the member, and delete the project only after all dependent assertions. Destructive requests make later project-scoped requests fail. + +For a runner pass, select only a coherent sequence. The collection contains both first-run success requests and behavior demonstrations whose expected status depends on prior state; do not mistake an intentionally replayed request for a clean-run success case. Review each failure in context. + +**Expected outcome:** captured IDs connect the workflow, owner/editor authorization behaves as documented, and non-destructive success requests pass. Record any deliberate conflict or cleanup behavior separately. + +## 8. Documentation and course usage + +Read the collection description and each folder description before the corresponding course module. During later labs: + +- use **Health** for operations and smoke checks; +- use **Auth** and member-specific requests for authentication/authorization work; +- use **Projects** and **Tasks** for HTTP design, CRUD, testing, and persistence; +- use **Webhooks** for signed, idempotent import behavior; +- use saved examples and test results as evidence, not as a replacement for pytest, OpenAPI, lint, or site checks. + +When implementation behavior changes in a later exercise, update the contract, request, tests, examples, and documentation together only when the exercise explicitly calls for that change. Preserve current request behavior in this prerequisite. + +## Troubleshooting + +**Connection refused or wrong host.** Confirm Uvicorn is running and inspect the resolved `base_url`, including environment overrides. + +**401 on a protected request.** Run registration/token bootstrap, confirm the correct token variable is populated, and check whether request-level auth overrides folder auth. + +**403 with a valid token.** Authentication succeeded but the identity lacks the required project role. Confirm membership setup and whether owner or member auth is active. + +**404 on project/task routes.** Confirm `project_id` or `task_id` was captured and that cleanup did not already delete the resource. + +**409 during registration.** The durable SQLite database already contains the email. Use another disposable address or a deliberately isolated database. + +**409 duplicate webhook.** `webhook_event_id` is an idempotency key. Use a new value only for a genuinely new lab event. + +**422 response.** Inspect the JSON body, resolved variables, and field constraints; do not weaken the test before understanding the response. + +**Tests parse the wrong response shape.** Start from a clean/coherent sequence. A request expecting success may receive a problem response when a prerequisite failed. + +## Security guidance + +- Keep the service bound to local development unless it is configured for exposure. +- Never reuse the example passwords or development secrets outside disposable local data. +- Keep real secret values in local/session scope; do not commit them in collection or environment files. +- Treat access tokens and webhook signatures as credentials; do not paste them into screenshots, examples, logs, or documentation. +- Do not reset or delete a database unless you created it as disposable lab state. +- Review scripts before running collections obtained from any source; scripts can read and modify scoped values and requests. + +## Knowledge check + +1. Why can an environment `base_url` change a request that already has a collection `base_url`? +2. What is the difference between a saved example and a live response? +3. Which script phase signs the webhook body, and why must signing happen after substitution? +4. Why does **Create project** have to run before project-scoped requests? +5. How does request-level member auth differ from folder-inherited owner auth? +6. Why should a 403 not be fixed by obtaining a syntactically valid token alone? +7. Which values are safe to share, and which must remain local/session-only? +8. Why should cleanup requests run last? + +## Practical completion checklist + +- [ ] TaskBox starts locally and all three Health requests return 200 with passing tests. +- [ ] I can find collection, folder, request, example, and definition files in the local workspace. +- [ ] I can explain variable precedence and inspect the resolved scope of `base_url`. +- [ ] I kept real secrets/tokens out of shared files and understand local/session values. +- [ ] I can identify no-auth, request Bearer auth, and inherited folder Bearer auth. +- [ ] I inspected collection/request tests and the webhook before-request script. +- [ ] I distinguished saved examples from live responses. +- [ ] I bootstrapped an owner and verified captured ID/token workflow state. +- [ ] I ran or carefully staged a coherent TaskBox workflow in dependency order. +- [ ] I can use the collection documentation to choose requests for later modules. +- [ ] I answered the knowledge check and recorded any unresolved troubleshooting notes. + +Completion of this checklist is required before starting `course/labs/01-http-api-design/`. diff --git a/course/labs/01-http-api-design/README.md b/course/labs/01-http-api-design/README.md index 2a163b3..174cf70 100644 --- a/course/labs/01-http-api-design/README.md +++ b/course/labs/01-http-api-design/README.md @@ -1,5 +1,7 @@ # Lab 01: HTTP and API design +Before starting, complete the required Postman prerequisite at `course/labs/00-postman-prerequisite/README.md`. + Learn to turn a small requirement into a predictable HTTP contract. This lab uses no third-party packages: the contract is an OpenAPI document and the solution includes a tiny standard-library server you can run and inspect. diff --git a/course/labs/03-crud/README.md b/course/labs/03-crud/README.md index 238e8f2..269ff92 100644 --- a/course/labs/03-crud/README.md +++ b/course/labs/03-crud/README.md @@ -6,4 +6,5 @@ later replaces this repository with SQLite and then PostgreSQL. Run with `uv run uvicorn solution.app:app --reload`. Explore the generated docs at `/docs`. The starter is intentionally incomplete. A missing note must -return 404, creation 201, replacement 200, and deletion 204. +return 404, creation 201, replacement 200, and deletion 204. `PUT` is +replace-only rather than an upsert, so replacing a missing note returns 404. diff --git a/course/labs/06-persistence/README.md b/course/labs/06-persistence/README.md index b0efa3c..324872f 100644 --- a/course/labs/06-persistence/README.md +++ b/course/labs/06-persistence/README.md @@ -13,5 +13,6 @@ curl http://127.0.0.1:8006/api/v1/tasks The starter leaves repository methods incomplete. Notice the transaction boundary: commit writes, rollback is automatic on an exception, and reads do -not mutate state. In a production service, replace `create_all` with Alembic -migrations. +not mutate state. In a production service, use a reviewed, version-controlled +schema-change process rather than `create_all`; this repository intentionally +ships no migration CLI or migration history. diff --git a/course/labs/07-operations/README.md b/course/labs/07-operations/README.md index 0393fd3..dc2ae74 100644 --- a/course/labs/07-operations/README.md +++ b/course/labs/07-operations/README.md @@ -3,6 +3,8 @@ Make failure visible and startup repeatable. Add `/healthz` (process health), `/readyz` (dependency readiness), structured request IDs, and graceful shutdown. Then run the provided Compose file with the API and PostgreSQL services. +This lab intentionally ships no migration command or migration history; keep +reviewed schema changes as a separate deployment concern. ```bash uv run uvicorn solution.app:app --port 8007 diff --git a/course/labs/10-webhooks-events/README.md b/course/labs/10-webhooks-events/README.md index 587bcb3..646d440 100644 --- a/course/labs/10-webhooks-events/README.md +++ b/course/labs/10-webhooks-events/README.md @@ -4,3 +4,7 @@ Verify the raw request body before decoding JSON. The solution uses HMAC SHA-256 with a timestamped `t=...,v1=...` header, rejects stale requests, and deduplicates event IDs so retries are safe. Never log the secret or trust an event merely because its JSON parses successfully. + +This is a standalone teaching contract. It is intentionally not the TaskBox +reference application's `X-Webhook-Event-ID` plus `X-Webhook-Signature: +sha256=...` contract. diff --git a/legacy/fixed_flask_api/app.py b/legacy/fixed_flask_api/app.py index 2989a46..3f0ce1a 100644 --- a/legacy/fixed_flask_api/app.py +++ b/legacy/fixed_flask_api/app.py @@ -181,6 +181,8 @@ def create_joke() -> Response: def replace_joke(joke_id: int) -> Response: try: values = validate_joke_payload(_json_body()) + except MalformedJSONError as exc: + return _malformed_json_problem(exc) except ValidationError as exc: return _validation_problem(exc) joke = app.extensions["joke_store"].replace(joke_id, **values) diff --git a/postman/collections/TaskBox API/.resources/definition.yaml b/postman/collections/TaskBox API/.resources/definition.yaml new file mode 100644 index 0000000..bf1e22b --- /dev/null +++ b/postman/collections/TaskBox API/.resources/definition.yaml @@ -0,0 +1,124 @@ +$kind: collection +name: TaskBox API +description: |- + # API Engineering with TaskBox course collection + + This is the runnable Postman companion to the repository's **API Engineering with TaskBox** course. The course takes learners from beginner API design to a production-minded FastAPI capstone using Python, SQLite, authentication, testing, webhooks, observability, and deployment practices. It is self-paced, designed as an approximately 43-hour sequence, and contains required Prerequisite 00 plus Labs 01-11. The authoritative sequence and outcomes are in `course/course-map.yml`; repository setup is in `README.md`; each exercise is under `course/labs/`. + + Complete `course/labs/00-postman-prerequisite/README.md` before Lab 01. It teaches this local collection layout, live requests/responses, variables and scopes, environment overrides and secret hygiene, inherited/request authorization, scripts and tests, saved examples, workflow runs, and course documentation without changing request behavior. + + ## Audience, format, and prerequisites + + The material is for learners building practical API-engineering skills, while the complete TaskBox workflow also supports experienced developers who want an executable reference. Use Python 3.13+, Node 24 LTS, and `uv`. From the repository root run `uv sync --all-groups --frozen`, then start TaskBox with `uv run uvicorn taskbox.main:app --reload`. The collection's `base_url` defaults to `http://127.0.0.1:8000`; generated API documentation is at `http://127.0.0.1:8000/docs`. The optional course site starts with `cd site && npm ci && npm run dev`. + + A persistent `taskbox.db` means repeated registration can return 409. Change `owner_email` and `member_email`, or reset only a disposable local database. Collection defaults are development-only. Before non-local use, provide long random `TASKBOX_JWT_SECRET` and `TASKBOX_WEBHOOK_SECRET` values and update the Postman session value of `webhook_secret`. + + ## How this collection supports the prerequisite and Labs 01-11 + + 1. **Postman foundations with TaskBox** (`course/labs/00-postman-prerequisite/`): learn the local workspace/collection structure, requests and live responses, scoped variables, environment overrides, secret hygiene, authorization inheritance, scripts/tests, examples, runner order, and documentation using this collection. Complete it before Lab 01. + + 2. **HTTP and API design** (`course/labs/01-http-api-design/`): inspect request methods, resource paths, status assertions, examples, content types, and RFC 9457-style problem responses. The lab's small contract/server is standalone; this collection demonstrates the same design concerns in the integrated TaskBox API. + + 3. **FastAPI foundations** (`course/labs/02-fastapi-basics/`): compare typed validation and generated OpenAPI with the request bodies and 422 expectations here. The greetings lab service is separate from TaskBox. + + 4. **Resource-oriented CRUD** (`course/labs/03-crud/`): use Projects and Tasks to exercise predictable create, read, update, delete, PATCH, 201/200/204/404 behavior, and resource lifecycles. The notes lab service remains a separate teaching service. + + 5. **API testing and clients** (`course/labs/04-testing-client-usage/`): use saved examples and response scripts as executable HTTP checks alongside the lab's pytest and Python-client exercises. Collection-level checks validate response time, JSON content type, and TaskBox problem shape; request checks validate statuses and response data. + + 6. **Authentication, authorization, and API security** (`course/labs/05-auth-security/`): Auth issues and captures JWTs; Members and member-specific request variants demonstrate owner, editor, and viewer boundaries. The focused port-8005 lab service is separate. + + 7. **SQLite-first persistence** (`course/labs/06-persistence/`): run project/task CRUD across restarts to observe durable TaskBox state, repository behavior, conflicts, and transaction-visible results. The focused port-8006 persistence service is separate. + + 8. **Operations and PostgreSQL transition** (`course/labs/07-operations/`): Health maps directly to liveness/readiness outcomes. Use the root service for collection calls; the lab's Compose/PostgreSQL transition and port-8007 service are separate deployment exercises. + + 9. **GraphQL and gRPC survey** (`course/labs/08-graphql-grpc/`): use TaskBox REST as the public compatibility baseline for comparison. GraphQL and gRPC run as standalone services from `course/examples/graphql/` and `course/examples/grpc/`; they are not represented by these HTTP requests. + + 10. **WebSockets and server-sent events** (`course/labs/09-realtime/`): compare TaskBox HTTP task operations with the lab's event delivery model. The WebSocket `/ws` and SSE `/events` service runs separately on port 8009 and is not exercised by this HTTP collection. + + 11. **Signed webhooks and event processing** (`course/labs/10-webhooks-events/`): Webhooks computes the HMAC over the substituted raw body, sends an event ID, and checks idempotent import behavior. Use the lab to study timestamped signatures and retry-safe processing in isolation. + + 12. **TaskBox capstone and deployment** (`course/labs/11-capstone-deployment/`): run the end-to-end workflow below as a smoke/contract suite covering JWTs, roles, pagination, persistence, signed imports, probes, and cleanup. Pair it with the required architecture diagram, threat model, migration command, rollback plan, failure/recovery note, and deployment rehearsal. + + + ## Recommended Postman learning and run sequence + + Start with required Prerequisite 00, then read `README.md`, `course/course-map.yml`, and the current lab README before using the corresponding folders. First send Health check, Liveness check, and Readiness check. In Auth, register the owner, get the owner token, verify the current owner, then register the member, get the member token, and verify the current member. Next create and list a project; its response script captures `project_id`. Add/list/update membership and run the member authorization examples. Then create/get/list/update tasks and run the editor/viewer-oriented examples; task creation captures `task_id`. Finally send Import tasks with a fresh business event ID, verify the resulting task/list state, and run delete/cleanup requests only after the assertions you need. + + Run requests in their folder order because response scripts save `owner_id`, `member_id`, JWTs, `project_id`, `task_id`, and cursors as collection variables. Protected folders inherit the owner Bearer token; member authorization examples explicitly use `member_access_token`. + + ## Folder/request-to-outcome map + + - **Health**: Health check, Liveness check, Readiness check -> operational probes, dependency readiness, and smoke-test entry points (Modules 7 and 11). + + - **Auth**: Register owner/member, Get owner/member token, Get current owner/member -> validated inputs, JWT lifecycle, identity, and authentication failures (Modules 2, 5, and 11). + + - **Projects**: Create/Get/List/Update/Delete project -> resource-oriented CRUD, partial updates, cursor pagination, persistence, and owner permissions (Modules 1, 3, 6, and 11). + + - **Members**: Add/List/Update role/Remove member plus Member can list members -> project role policy, forbidden/conflict/not-found behavior, and authorization testing (Modules 4, 5, and 11). + + - **Tasks**: Create/Get/List/Update/Delete task plus member read/editor-create variants -> CRUD lifecycle, validation/filtering/pagination, persisted state, and role enforcement (Modules 3, 4, 5, 6, and 11). + + - **Webhooks**: Import tasks -> raw-body HMAC signing, event identity, idempotency, authorization, and retry-safe integration (Modules 10 and 11). + + + ## Capstone workflow and behavior notes + + Health probes -> register owner/member -> issue owner/member tokens -> inspect `/me` -> create/list/update a project -> manage membership -> create/list/update tasks -> signed webhook import -> verify state -> cleanup. Owners can read/write projects, memberships, and tasks. Editors can read and write tasks but cannot change project metadata or memberships. Viewers can read project data and tasks but cannot write tasks. Webhook import accepts an optional JWT; without one, the verified body must contain `actor_id`. + + Project, membership, and task lists return `{items, next_cursor}`. `limit` is 1-100 (default 50); keep filters unchanged while following an opaque cursor. Task status is `todo`, `in_progress`, `done`, or `archived`; priority is 0-4. Domain failures use `application/problem+json` with stable codes including `authentication_required`, `forbidden`, `not_found`, `conflict`, `duplicate_webhook`, `validation_error`, and `invalid_cursor`. FastAPI's missing-Bearer path may use its default JSON error shape. + + ## Validation and completion expectations + + Treat a run as evidence, not a substitute for the course checks. Review every status/body assertion and investigate failures; verify JSON/problem content types, stable problem fields, role-denial cases, cursor behavior, webhook replay conflict, and sub-two-second local responses. Course completion requires Prerequisite 00, Labs 01-11, 43 hours, and the checks declared in `course/course-map.yml`: `pytest`, `ruff`, `openapi-contract`, `astro-check`, and `astro-build`. Repository commands are documented in `README.md`: `uv run pytest`, `uv run ruff check .`, `uv run python scripts/validate_course_map.py`, `uv run python scripts/check_openapi_contract.py`, then `npm ci`, `npm run check`, and `npm run build` from `site/`. + + ## Repository paths + + - Course map: `course/course-map.yml` + + - Lab guides: `course/labs/00-postman-prerequisite/README.md`, then `course/labs/01-http-api-design/README.md` through `course/labs/11-capstone-deployment/README.md` + + - TaskBox implementation: `src/taskbox/` + + - OpenAPI contract checks: `scripts/check_openapi_contract.py` + + - Course-map validation: `scripts/validate_course_map.py` + + - Root startup and verification guide: `README.md` +variables: + base_url: http://127.0.0.1:8000 + owner_email: ali.shaikh@example.com + owner_password: correct-horse-battery-staple + owner_display_name: Ali Mustufa + owner_id: "" + owner_access_token: "" + member_email: member.postman@example.com + member_password: correct-horse-battery-staple + member_display_name: Postman Member + member_id: "" + member_access_token: "" + project_id: "" + task_id: "" + cursor: "" + webhook_secret: dev-webhook-secret + webhook_event_id: evt-postman-import-001 +scripts: + - type: http:afterResponse + code: |- + pm.test('Response time is under 2 seconds', function () { + pm.expect(pm.response.responseTime).to.be.below(2000); + }); + const status = pm.response.code; + if (status !== 204) { + pm.test('Response has a JSON content type', function () { + pm.expect(pm.response.headers.get('Content-Type') || '').to.match(/application\/(json|problem\+json)/i); + }); + } + if (status >= 400 && (pm.response.headers.get('Content-Type') || '').includes('application/problem+json')) { + pm.test('Problem response follows the TaskBox problem shape', function () { + const body = pm.response.json(); + pm.expect(body).to.include.all.keys('type', 'title', 'status', 'detail', 'instance', 'code'); + pm.expect(body.status).to.eql(status); + pm.expect(body.type).to.match(/^https:\/\/taskbox\.dev\/problems\//); + }); + } + language: text/javascript diff --git a/postman/collections/TaskBox API/Auth/.resources/Get owner token.resources/examples/200 OK.example.yaml b/postman/collections/TaskBox API/Auth/.resources/Get owner token.resources/examples/200 OK.example.yaml new file mode 100644 index 0000000..9c81144 --- /dev/null +++ b/postman/collections/TaskBox API/Auth/.resources/Get owner token.resources/examples/200 OK.example.yaml @@ -0,0 +1,16 @@ +$kind: http-example +name: 200 OK +request: + url: '{{base_url}}/api/v1/auth/token' + method: POST +response: + statusCode: 200 + statusText: OK + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + {"access_token":"eyJ...","token_type":"bearer","expires_in":3600} +order: 1000 diff --git a/postman/collections/TaskBox API/Auth/.resources/Register owner.resources/examples/201 Created.example.yaml b/postman/collections/TaskBox API/Auth/.resources/Register owner.resources/examples/201 Created.example.yaml new file mode 100644 index 0000000..2ef5a16 --- /dev/null +++ b/postman/collections/TaskBox API/Auth/.resources/Register owner.resources/examples/201 Created.example.yaml @@ -0,0 +1,23 @@ +$kind: http-example +name: 201 Created +request: + url: '{{base_url}}/api/v1/auth/register' + method: POST +response: + statusCode: 201 + statusText: Created + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + { + "id": "5f6889f6-35fd-4af0-97a7-90cd57070bf8", + "email": "ali.shaikh@example.com", + "display_name": "Ali Mustufa", + "status": "active", + "created_at": "2030-01-01T12:00:00Z", + "updated_at": "2030-01-01T12:00:00Z" + } +order: 1000 diff --git a/postman/collections/TaskBox API/Auth/.resources/Register owner.resources/examples/409 Conflict.example.yaml b/postman/collections/TaskBox API/Auth/.resources/Register owner.resources/examples/409 Conflict.example.yaml new file mode 100644 index 0000000..27f10ad --- /dev/null +++ b/postman/collections/TaskBox API/Auth/.resources/Register owner.resources/examples/409 Conflict.example.yaml @@ -0,0 +1,23 @@ +$kind: http-example +name: 409 Conflict +request: + url: '{{base_url}}/api/v1/auth/register' + method: POST +response: + statusCode: 409 + statusText: Conflict + headers: + - key: Content-Type + value: application/problem+json + body: + type: json + content: |- + { + "type": "https://taskbox.dev/problems/conflict", + "title": "Conflict", + "status": 409, + "detail": "email is already registered", + "instance": "http://127.0.0.1:8000/api/v1/auth/register", + "code": "conflict" + } +order: 2000 diff --git a/postman/collections/TaskBox API/Auth/.resources/definition.yaml b/postman/collections/TaskBox API/Auth/.resources/definition.yaml new file mode 100644 index 0000000..952a349 --- /dev/null +++ b/postman/collections/TaskBox API/Auth/.resources/definition.yaml @@ -0,0 +1,7 @@ +$kind: collection +name: Auth +description: |- + Account registration and JWT authentication workflow. Run owner registration/token first, then member registration/token. Registration lowercases emails; passwords require at least eight characters; display names are 1-120 characters. Duplicate email is 409. Token responses contain an HS256 JWT with issuer `taskbox`, subject=user UUID, and default 3600-second expiry. + + Course mapping: use this folder with `course/labs/02-fastapi-basics/README.md` for typed input validation, `course/labs/05-auth-security/README.md` for JWT and current-user boundaries, and `course/labs/11-capstone-deployment/README.md` for the capstone identity bootstrap. The focused Lab 05 service is separate; these requests target the integrated TaskBox application. +order: 2000 diff --git a/postman/collections/TaskBox API/Auth/Get current member.request.yaml b/postman/collections/TaskBox API/Auth/Get current member.request.yaml new file mode 100644 index 0000000..448b41a --- /dev/null +++ b/postman/collections/TaskBox API/Auth/Get current member.request.yaml @@ -0,0 +1,20 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/me' +order: 6000 +description: |- + Confirms that the secondary JWT resolves to the captured member identity. +auth: + type: bearer + credentials: + - key: token + value: '{{member_access_token}}' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Current member returns 200', function () { + pm.response.to.have.status(200); + }); + const body = pm.response.json(); + pm.expect(body.id).to.eql(pm.collectionVariables.get('member_id')); diff --git a/postman/collections/TaskBox API/Auth/Get current owner.request.yaml b/postman/collections/TaskBox API/Auth/Get current owner.request.yaml new file mode 100644 index 0000000..b809d19 --- /dev/null +++ b/postman/collections/TaskBox API/Auth/Get current owner.request.yaml @@ -0,0 +1,23 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/me' +order: 3000 +description: |- + Returns the active user identified by the owner JWT. Missing, invalid, expired, wrong-issuer, or inactive-user credentials return 401 with a Bearer challenge. +auth: + type: bearer + credentials: + - key: token + value: '{{owner_access_token}}' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Current owner returns 200', function () { + pm.response.to.have.status(200); + }); + const body = pm.response.json(); + pm.test('Current owner matches captured identity', function () { + pm.expect(body.id).to.eql(pm.collectionVariables.get('owner_id')); + pm.expect(body.email).to.eql(pm.collectionVariables.get('owner_email').toLowerCase()); + }); diff --git a/postman/collections/TaskBox API/Auth/Get member token.request.yaml b/postman/collections/TaskBox API/Auth/Get member token.request.yaml new file mode 100644 index 0000000..a3d47d5 --- /dev/null +++ b/postman/collections/TaskBox API/Auth/Get member token.request.yaml @@ -0,0 +1,28 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/auth/token' +order: 5000 +description: |- + Exchanges secondary-user credentials for a Bearer JWT and stores it for member authorization checks. +auth: + type: noauth +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "email": "{{member_email}}", + "password": "{{member_password}}" + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Member token exchange returns 200', function () { + pm.response.to.have.status(200); + }); + const body = pm.response.json(); + pm.expect(body.token_type).to.eql('bearer'); + pm.collectionVariables.set('member_access_token', body.access_token); diff --git a/postman/collections/TaskBox API/Auth/Get owner token.request.yaml b/postman/collections/TaskBox API/Auth/Get owner token.request.yaml new file mode 100644 index 0000000..f45f043 --- /dev/null +++ b/postman/collections/TaskBox API/Auth/Get owner token.request.yaml @@ -0,0 +1,33 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/auth/token' +order: 2000 +description: |- + Exchanges owner credentials for a short-lived Bearer JWT. Invalid credentials return 401. The default lifetime is 3600 seconds and is configurable by TASKBOX_JWT_EXPIRES. +auth: + type: noauth +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "email": "{{owner_email}}", + "password": "{{owner_password}}" + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Owner token exchange returns 200', function () { + pm.response.to.have.status(200); + }); + const body = pm.response.json(); + pm.test('Token response has the documented shape', function () { + pm.expect(body).to.include.all.keys('access_token', 'token_type', 'expires_in'); + pm.expect(body.token_type).to.eql('bearer'); + pm.expect(body.expires_in).to.be.a('number').and.above(0); + }); + pm.collectionVariables.set('owner_access_token', body.access_token); +examples: './.resources/Get owner token.resources/examples' diff --git a/postman/collections/TaskBox API/Auth/Register member.request.yaml b/postman/collections/TaskBox API/Auth/Register member.request.yaml new file mode 100644 index 0000000..38493d2 --- /dev/null +++ b/postman/collections/TaskBox API/Auth/Register member.request.yaml @@ -0,0 +1,32 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/auth/register' +order: 4000 +description: |- + Creates the secondary user used to exercise membership roles. Returns 201; duplicate email returns 409; invalid input returns 422. +auth: + type: noauth +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "email": "{{member_email}}", + "password": "{{member_password}}", + "display_name": "{{member_display_name}}" + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Member registration returns 201', function () { + pm.response.to.have.status(201); + }); + const body = pm.response.json(); + pm.test('Member user response is complete', function () { + pm.expect(body).to.include.all.keys('id', 'email', 'display_name', 'status', 'created_at', 'updated_at'); + pm.expect(body.status).to.eql('active'); + }); + pm.collectionVariables.set('member_id', body.id); diff --git a/postman/collections/TaskBox API/Auth/Register owner.request.yaml b/postman/collections/TaskBox API/Auth/Register owner.request.yaml new file mode 100644 index 0000000..3328aee --- /dev/null +++ b/postman/collections/TaskBox API/Auth/Register owner.request.yaml @@ -0,0 +1,35 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/auth/register' +order: 1000 +description: |- + Creates the primary workflow user. Email is normalized to lowercase. Requires an email containing `@`, a password of at least 8 characters, and display_name of 1-120 characters. Returns 201; duplicate email returns 409; invalid input returns 422. +auth: + type: noauth +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "email": "{{owner_email}}", + "password": "{{owner_password}}", + "display_name": "{{owner_display_name}}" + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Owner registration returns 201', function () { + pm.response.to.have.status(201); + }); + const body = pm.response.json(); + pm.test('User response is complete and excludes credentials', function () { + pm.expect(body).to.include.all.keys('id', 'email', 'display_name', 'status', 'created_at', 'updated_at'); + pm.expect(body).not.to.have.any.keys('password', 'password_hash'); + pm.expect(body.status).to.eql('active'); + pm.expect(body.email).to.eql(pm.collectionVariables.get('owner_email').toLowerCase()); + }); + pm.collectionVariables.set('owner_id', body.id); +examples: './.resources/Register owner.resources/examples' diff --git a/postman/collections/TaskBox API/Health/.resources/Health check.resources/examples/200 OK.example.yaml b/postman/collections/TaskBox API/Health/.resources/Health check.resources/examples/200 OK.example.yaml new file mode 100644 index 0000000..65936c4 --- /dev/null +++ b/postman/collections/TaskBox API/Health/.resources/Health check.resources/examples/200 OK.example.yaml @@ -0,0 +1,16 @@ +$kind: http-example +name: 200 OK +request: + url: '{{base_url}}/healthz' + method: GET +response: + statusCode: 200 + statusText: OK + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + {"status":"ok"} +order: 1000 diff --git a/postman/collections/TaskBox API/Health/.resources/definition.yaml b/postman/collections/TaskBox API/Health/.resources/definition.yaml new file mode 100644 index 0000000..90105af --- /dev/null +++ b/postman/collections/TaskBox API/Health/.resources/definition.yaml @@ -0,0 +1,7 @@ +$kind: collection +name: Health +description: |- + Unauthenticated process probes. `/healthz` and `/livez` report process liveness. `/readyz` additionally executes `SELECT 1` against the configured SQLite connection. `/livez` and `/readyz` are implemented but intentionally excluded from generated OpenAPI. + + Course mapping: this folder directly supports operations outcomes in `course/labs/07-operations/README.md` and the smoke-test/readiness acceptance work in `course/labs/11-capstone-deployment/README.md`. Send these first; readiness should become non-2xx when its database dependency is unavailable. +order: 1000 diff --git a/postman/collections/TaskBox API/Health/Health check.request.yaml b/postman/collections/TaskBox API/Health/Health check.request.yaml new file mode 100644 index 0000000..1021473 --- /dev/null +++ b/postman/collections/TaskBox API/Health/Health check.request.yaml @@ -0,0 +1,17 @@ +$kind: http-request +name: Health check +method: GET +url: "{{base_url}}/healthz" +order: 1000 +description: "Public health endpoint included in OpenAPI. Returns 200 with `status: ok` when the application process is serving requests. It does not query the database; use readiness for that." +auth: + type: noauth +scripts: + - type: afterResponse + code: |- + pm.test('Health check returns 200 and ok', function () { + pm.response.to.have.status(200); + pm.expect(pm.response.json()).to.eql({ status: 'ok' }); + }); + language: text/javascript +examples: ./.resources/Health check.resources/examples diff --git a/postman/collections/TaskBox API/Health/Liveness check.request.yaml b/postman/collections/TaskBox API/Health/Liveness check.request.yaml new file mode 100644 index 0000000..0caac13 --- /dev/null +++ b/postman/collections/TaskBox API/Health/Liveness check.request.yaml @@ -0,0 +1,16 @@ +$kind: http-request +method: GET +url: '{{base_url}}/livez' +order: 2000 +description: |- + Public liveness alias implemented by the app but excluded from OpenAPI. Returns 200 with `status: ok` when the process can handle HTTP. +auth: + type: noauth +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Liveness returns 200 and ok', function () { + pm.response.to.have.status(200); + pm.expect(pm.response.json()).to.eql({ status: 'ok' }); + }); diff --git a/postman/collections/TaskBox API/Health/Readiness check.request.yaml b/postman/collections/TaskBox API/Health/Readiness check.request.yaml new file mode 100644 index 0000000..80ae7a2 --- /dev/null +++ b/postman/collections/TaskBox API/Health/Readiness check.request.yaml @@ -0,0 +1,16 @@ +$kind: http-request +method: GET +url: '{{base_url}}/readyz' +order: 3000 +description: |- + Public readiness endpoint implemented by the app but excluded from OpenAPI. Executes `SELECT 1` using the configured SQLite connection, then returns 200 with `status: ok`. +auth: + type: noauth +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Readiness returns 200 and ok', function () { + pm.response.to.have.status(200); + pm.expect(pm.response.json()).to.eql({ status: 'ok' }); + }); diff --git a/postman/collections/TaskBox API/Members/.resources/Add member.resources/examples/201 Created.example.yaml b/postman/collections/TaskBox API/Members/.resources/Add member.resources/examples/201 Created.example.yaml new file mode 100644 index 0000000..60f4433 --- /dev/null +++ b/postman/collections/TaskBox API/Members/.resources/Add member.resources/examples/201 Created.example.yaml @@ -0,0 +1,22 @@ +$kind: http-example +name: 201 Created +request: + url: '{{base_url}}/api/v1/projects/{{project_id}}/members' + method: POST +response: + statusCode: 201 + statusText: Created + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + { + "project_id": "c5d95506-ab1d-48d7-83b5-288e89d55947", + "user_id": "112e4c0a-8889-437e-9b5d-e6cbf8d1a839", + "role": "viewer", + "created_at": "2030-01-01T12:10:00Z", + "updated_at": "2030-01-01T12:10:00Z" + } +order: 1000 diff --git a/postman/collections/TaskBox API/Members/.resources/definition.yaml b/postman/collections/TaskBox API/Members/.resources/definition.yaml new file mode 100644 index 0000000..121bdf4 --- /dev/null +++ b/postman/collections/TaskBox API/Members/.resources/definition.yaml @@ -0,0 +1,12 @@ +$kind: collection +name: Members +description: |- + Project-scoped membership management. Any project member may list memberships; only the owner may add, update, or remove them. Assignable roles are `editor` and `viewer`; `owner` is reserved. Re-adding a member is 409, missing users/memberships are 404, and removing the owner is 409. + + Course mapping: these requests turn the owner/editor/viewer policy from `course/labs/05-auth-security/README.md` into executable authorization checks and support the integration/testing outcomes in `course/labs/04-testing-client-usage/README.md` and `course/labs/11-capstone-deployment/README.md`. Create the project and both user tokens before running this folder. +order: 4000 +auth: + type: bearer + credentials: + - key: token + value: '{{owner_access_token}}' diff --git a/postman/collections/TaskBox API/Members/Add member.request.yaml b/postman/collections/TaskBox API/Members/Add member.request.yaml new file mode 100644 index 0000000..38f2df7 --- /dev/null +++ b/postman/collections/TaskBox API/Members/Add member.request.yaml @@ -0,0 +1,31 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/projects/:project_id/members' +order: 2000 +description: |- + Owner-only membership creation. Adds the captured secondary user as viewer. Roles `viewer` and `editor` are assignable; owner is reserved. Existing membership returns 409 and unknown user returns 404. +pathVariables: + - key: project_id + value: '{{project_id}}' +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "user_id": "{{member_id}}", + "role": "viewer" + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Membership creation returns 201', function () { pm.response.to.have.status(201); }); + const body = pm.response.json(); + pm.test('Viewer membership matches workflow IDs', function () { + pm.expect(body.project_id).to.eql(pm.collectionVariables.get('project_id')); + pm.expect(body.user_id).to.eql(pm.collectionVariables.get('member_id')); + pm.expect(body.role).to.eql('viewer'); + }); +examples: './.resources/Add member.resources/examples' diff --git a/postman/collections/TaskBox API/Members/List members.request.yaml b/postman/collections/TaskBox API/Members/List members.request.yaml new file mode 100644 index 0000000..ea6e60b --- /dev/null +++ b/postman/collections/TaskBox API/Members/List members.request.yaml @@ -0,0 +1,23 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/projects/:project_id/members' +order: 1000 +description: |- + Lists project memberships for any authenticated member. Uses cursor pagination with `limit` 1-100 and returns `{items, next_cursor}`. +pathVariables: + - key: project_id + value: '{{project_id}}' +queryParams: + - key: limit + value: '50' + - key: cursor + value: '{{cursor}}' + disabled: true +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Membership list returns 200', function () { pm.response.to.have.status(200); }); + const body = pm.response.json(); + pm.expect(body.items).to.be.an('array'); + pm.expect(body).to.have.property('next_cursor'); diff --git a/postman/collections/TaskBox API/Members/Member can list members.request.yaml b/postman/collections/TaskBox API/Members/Member can list members.request.yaml new file mode 100644 index 0000000..9e8d51b --- /dev/null +++ b/postman/collections/TaskBox API/Members/Member can list members.request.yaml @@ -0,0 +1,22 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/projects/:project_id/members' +order: 4000 +description: |- + Authorization example: the secondary project member (viewer or editor) may list memberships. This request overrides folder auth with the member token. +auth: + type: bearer + credentials: + - key: token + value: '{{member_access_token}}' +pathVariables: + - key: project_id + value: '{{project_id}}' +queryParams: + - key: limit + value: '50' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('A project member can list memberships', function () { pm.response.to.have.status(200); }); diff --git a/postman/collections/TaskBox API/Members/Remove member.request.yaml b/postman/collections/TaskBox API/Members/Remove member.request.yaml new file mode 100644 index 0000000..8b2e908 --- /dev/null +++ b/postman/collections/TaskBox API/Members/Remove member.request.yaml @@ -0,0 +1,19 @@ +$kind: http-request +method: DELETE +url: '{{base_url}}/api/v1/projects/:project_id/members/:user_id' +order: 8000 +description: |- + Owner-only membership removal. Removing the owner returns 409; a missing membership returns 404. Run after member-role workflow checks and before project deletion. +pathVariables: + - key: project_id + value: '{{project_id}}' + - key: user_id + value: '{{member_id}}' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Membership removal returns empty 204', function () { + pm.response.to.have.status(204); + pm.expect(pm.response.text()).to.eql(''); + }); diff --git a/postman/collections/TaskBox API/Members/Update member role.request.yaml b/postman/collections/TaskBox API/Members/Update member role.request.yaml new file mode 100644 index 0000000..0072c5c --- /dev/null +++ b/postman/collections/TaskBox API/Members/Update member role.request.yaml @@ -0,0 +1,26 @@ +$kind: http-request +method: PATCH +url: '{{base_url}}/api/v1/projects/:project_id/members/:user_id' +order: 3000 +description: |- + Owner-only role change. Promotes the secondary member to editor. Assigning owner returns 422; a missing membership returns 404. +pathVariables: + - key: project_id + value: '{{project_id}}' + - key: user_id + value: '{{member_id}}' +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "role": "editor" + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Membership update returns 200', function () { pm.response.to.have.status(200); }); + pm.expect(pm.response.json().role).to.eql('editor'); diff --git a/postman/collections/TaskBox API/Projects/.resources/Create project.resources/examples/201 Created.example.yaml b/postman/collections/TaskBox API/Projects/.resources/Create project.resources/examples/201 Created.example.yaml new file mode 100644 index 0000000..1f652a6 --- /dev/null +++ b/postman/collections/TaskBox API/Projects/.resources/Create project.resources/examples/201 Created.example.yaml @@ -0,0 +1,23 @@ +$kind: http-example +name: 201 Created +request: + url: '{{base_url}}/api/v1/projects' + method: POST +response: + statusCode: 201 + statusText: Created + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + { + "id": "c5d95506-ab1d-48d7-83b5-288e89d55947", + "owner_id": "5f6889f6-35fd-4af0-97a7-90cd57070bf8", + "name": "Postman workflow project", + "description": "Local verification of TaskBox.", + "created_at": "2030-01-01T12:05:00Z", + "updated_at": "2030-01-01T12:05:00Z" + } +order: 1000 diff --git a/postman/collections/TaskBox API/Projects/.resources/List projects.resources/examples/200 OK.example.yaml b/postman/collections/TaskBox API/Projects/.resources/List projects.resources/examples/200 OK.example.yaml new file mode 100644 index 0000000..956895b --- /dev/null +++ b/postman/collections/TaskBox API/Projects/.resources/List projects.resources/examples/200 OK.example.yaml @@ -0,0 +1,16 @@ +$kind: http-example +name: 200 OK +request: + url: '{{base_url}}/api/v1/projects?limit=50' + method: GET +response: + statusCode: 200 + statusText: OK + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + {"items":[],"next_cursor":null} +order: 1000 diff --git a/postman/collections/TaskBox API/Projects/.resources/definition.yaml b/postman/collections/TaskBox API/Projects/.resources/definition.yaml new file mode 100644 index 0000000..f8f231d --- /dev/null +++ b/postman/collections/TaskBox API/Projects/.resources/definition.yaml @@ -0,0 +1,12 @@ +$kind: collection +name: Projects +description: |- + Project CRUD and cursor listing. Creating a project atomically creates the caller's owner membership. Any member may read; only the owner may patch or delete. PATCH is partial: omitted description is preserved, while explicit `null` clears it. Lists accept `limit` 1-100 and an opaque cursor. + + Course mapping: use this folder for HTTP/resource design (`course/labs/01-http-api-design/README.md`), lifecycle and status semantics (`course/labs/03-crud/README.md`), durable state (`course/labs/06-persistence/README.md`), and the integrated capstone (`course/labs/11-capstone-deployment/README.md`). Run Create project first so its script captures `project_id`. +order: 3000 +auth: + type: bearer + credentials: + - key: token + value: '{{owner_access_token}}' diff --git a/postman/collections/TaskBox API/Projects/Create project.request.yaml b/postman/collections/TaskBox API/Projects/Create project.request.yaml new file mode 100644 index 0000000..3b2f107 --- /dev/null +++ b/postman/collections/TaskBox API/Projects/Create project.request.yaml @@ -0,0 +1,28 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/projects' +order: 2000 +description: |- + Creates a project and atomically grants the caller owner membership. Name is required (1-160 characters); description is optional and at most 2000 characters. Returns 201. +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "name": "Postman workflow project", + "description": "Local verification of TaskBox projects, memberships, tasks, and webhooks." + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Project creation returns 201', function () { pm.response.to.have.status(201); }); + const body = pm.response.json(); + pm.test('Project response has identifiers and timestamps', function () { + pm.expect(body).to.include.all.keys('id', 'owner_id', 'name', 'description', 'created_at', 'updated_at'); + pm.expect(body.owner_id).to.eql(pm.collectionVariables.get('owner_id')); + }); + pm.collectionVariables.set('project_id', body.id); +examples: './.resources/Create project.resources/examples' diff --git a/postman/collections/TaskBox API/Projects/Delete project.request.yaml b/postman/collections/TaskBox API/Projects/Delete project.request.yaml new file mode 100644 index 0000000..d6f463e --- /dev/null +++ b/postman/collections/TaskBox API/Projects/Delete project.request.yaml @@ -0,0 +1,17 @@ +$kind: http-request +method: DELETE +url: '{{base_url}}/api/v1/projects/:project_id' +order: 9000 +description: |- + Owner-only project deletion. Cascades memberships and tasks. Returns 204 with no response body. Keep this last in a workflow; subsequent project-scoped requests will return 404. +pathVariables: + - key: project_id + value: '{{project_id}}' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Project deletion returns empty 204', function () { + pm.response.to.have.status(204); + pm.expect(pm.response.text()).to.eql(''); + }); diff --git a/postman/collections/TaskBox API/Projects/Get project.request.yaml b/postman/collections/TaskBox API/Projects/Get project.request.yaml new file mode 100644 index 0000000..8dbad3a --- /dev/null +++ b/postman/collections/TaskBox API/Projects/Get project.request.yaml @@ -0,0 +1,15 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/projects/:project_id' +order: 3000 +description: |- + Reads a project after verifying caller membership. A missing project returns 404; an authenticated non-member returns 403. +pathVariables: + - key: project_id + value: '{{project_id}}' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Project retrieval returns 200', function () { pm.response.to.have.status(200); }); + pm.expect(pm.response.json().id).to.eql(pm.collectionVariables.get('project_id')); diff --git a/postman/collections/TaskBox API/Projects/List projects.request.yaml b/postman/collections/TaskBox API/Projects/List projects.request.yaml new file mode 100644 index 0000000..a01cc3a --- /dev/null +++ b/postman/collections/TaskBox API/Projects/List projects.request.yaml @@ -0,0 +1,26 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/projects' +order: 1000 +description: |- + Lists projects visible to the authenticated user. Returns `{items, next_cursor}` ordered by creation time and UUID. `limit` must be 1-100; cursor is opaque. An invalid cursor returns 400 `invalid_cursor`. +queryParams: + - key: limit + value: '50' + description: Page size from 1 through 100. + - key: cursor + value: '{{cursor}}' + description: Opaque continuation token from a prior response. + disabled: true +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Project list returns 200', function () { pm.response.to.have.status(200); }); + const body = pm.response.json(); + pm.test('Project list is paginated', function () { + pm.expect(body.items).to.be.an('array'); + pm.expect(body).to.have.property('next_cursor'); + }); + if (body.next_cursor) pm.collectionVariables.set('cursor', body.next_cursor); +examples: './.resources/List projects.resources/examples' diff --git a/postman/collections/TaskBox API/Projects/Update project.request.yaml b/postman/collections/TaskBox API/Projects/Update project.request.yaml new file mode 100644 index 0000000..bdc4479 --- /dev/null +++ b/postman/collections/TaskBox API/Projects/Update project.request.yaml @@ -0,0 +1,26 @@ +$kind: http-request +method: PATCH +url: '{{base_url}}/api/v1/projects/:project_id' +order: 4000 +description: |- + Owner-only partial project update. Omitted fields remain unchanged; sending `description: null` clears the description. Editors/viewers receive 403. Empty or oversized fields return 422. +pathVariables: + - key: project_id + value: '{{project_id}}' +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "name": "Postman workflow project - updated", + "description": "Updated through the owner-only PATCH route." + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Project update returns 200', function () { pm.response.to.have.status(200); }); + const body = pm.response.json(); + pm.expect(body.name).to.eql('Postman workflow project - updated'); diff --git a/postman/collections/TaskBox API/Tasks/.resources/Create task.resources/examples/201 Created.example.yaml b/postman/collections/TaskBox API/Tasks/.resources/Create task.resources/examples/201 Created.example.yaml new file mode 100644 index 0000000..d033735 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/.resources/Create task.resources/examples/201 Created.example.yaml @@ -0,0 +1,28 @@ +$kind: http-example +name: 201 Created +request: + url: '{{base_url}}/api/v1/projects/{{project_id}}/tasks' + method: POST +response: + statusCode: 201 + statusText: Created + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + { + "id": "278195c1-ad44-4c2f-9e92-e074e7c5151f", + "project_id": "c5d95506-ab1d-48d7-83b5-288e89d55947", + "created_by": "5f6889f6-35fd-4af0-97a7-90cd57070bf8", + "assignee_id": "112e4c0a-8889-437e-9b5d-e6cbf8d1a839", + "title": "Verify the TaskBox API", + "description": "Exercise the local collection.", + "status": "todo", + "priority": 2, + "due_at": "2030-01-15T17:00:00Z", + "created_at": "2030-01-01T12:15:00Z", + "updated_at": "2030-01-01T12:15:00Z" + } +order: 1000 diff --git a/postman/collections/TaskBox API/Tasks/.resources/List tasks.resources/examples/200 OK.example.yaml b/postman/collections/TaskBox API/Tasks/.resources/List tasks.resources/examples/200 OK.example.yaml new file mode 100644 index 0000000..1b9b010 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/.resources/List tasks.resources/examples/200 OK.example.yaml @@ -0,0 +1,16 @@ +$kind: http-example +name: 200 OK +request: + url: '{{base_url}}/api/v1/projects/{{project_id}}/tasks?limit=50' + method: GET +response: + statusCode: 200 + statusText: OK + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + {"items":[],"next_cursor":null} +order: 1000 diff --git a/postman/collections/TaskBox API/Tasks/.resources/definition.yaml b/postman/collections/TaskBox API/Tasks/.resources/definition.yaml new file mode 100644 index 0000000..245a4f3 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/.resources/definition.yaml @@ -0,0 +1,12 @@ +$kind: collection +name: Tasks +description: |- + Task CRUD and filtered cursor listing. Any project member may read. Owners/editors may create, patch, and delete; viewers receive 403. `title` is 1-240 characters, description up to 10,000, status is `todo|in_progress|done|archived`, priority is 0-4, and due_at is an ISO 8601 datetime. PATCH can clear description, assignee_id, or due_at with explicit null. + + Course mapping: this is the main executable resource lifecycle for `course/labs/03-crud/README.md`, the request/assertion companion to `course/labs/04-testing-client-usage/README.md`, a durable-state example for `course/labs/06-persistence/README.md`, and a central part of `course/labs/11-capstone-deployment/README.md`. Member-specific requests demonstrate the Lab 05 role outcomes. +order: 5000 +auth: + type: bearer + credentials: + - key: token + value: '{{owner_access_token}}' diff --git a/postman/collections/TaskBox API/Tasks/Create task.request.yaml b/postman/collections/TaskBox API/Tasks/Create task.request.yaml new file mode 100644 index 0000000..674ca44 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/Create task.request.yaml @@ -0,0 +1,38 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/projects/:project_id/tasks' +order: 2000 +description: |- + Creates a task as project owner. Owners and editors may write; viewers receive 403. Title is required (1-240 chars), description max 10000, priority 0-4, and due_at is ISO 8601. Assignee must reference an existing user. +pathVariables: + - key: project_id + value: '{{project_id}}' +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "title": "Verify the TaskBox API", + "description": "Exercise collection variables, authorization, pagination, and response contracts.", + "status": "todo", + "priority": 2, + "assignee_id": "{{member_id}}", + "due_at": "2030-01-15T17:00:00Z" + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Task creation returns 201', function () { pm.response.to.have.status(201); }); + const body = pm.response.json(); + pm.test('Task response has the documented fields', function () { + pm.expect(body).to.include.all.keys('id', 'project_id', 'created_by', 'assignee_id', 'title', 'description', 'status', 'priority', 'due_at', 'created_at', 'updated_at'); + pm.expect(body.project_id).to.eql(pm.collectionVariables.get('project_id')); + pm.expect(body.created_by).to.eql(pm.collectionVariables.get('owner_id')); + pm.expect(body.status).to.eql('todo'); + pm.expect(body.priority).to.eql(2); + }); + pm.collectionVariables.set('task_id', body.id); +examples: './.resources/Create task.resources/examples' diff --git a/postman/collections/TaskBox API/Tasks/Delete task.request.yaml b/postman/collections/TaskBox API/Tasks/Delete task.request.yaml new file mode 100644 index 0000000..ffc19fe --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/Delete task.request.yaml @@ -0,0 +1,17 @@ +$kind: http-request +method: DELETE +url: '{{base_url}}/api/v1/tasks/:task_id' +order: 8000 +description: |- + Owner/editor task deletion. Returns 204 with no response body. A viewer receives 403 and a missing task returns 404. +pathVariables: + - key: task_id + value: '{{task_id}}' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Task deletion returns empty 204', function () { + pm.response.to.have.status(204); + pm.expect(pm.response.text()).to.eql(''); + }); diff --git a/postman/collections/TaskBox API/Tasks/Get task.request.yaml b/postman/collections/TaskBox API/Tasks/Get task.request.yaml new file mode 100644 index 0000000..fa36fa8 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/Get task.request.yaml @@ -0,0 +1,17 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/tasks/:task_id' +order: 3000 +description: |- + Reads a task after deriving its project and checking caller membership. Missing task returns 404; authenticated non-member returns 403. +pathVariables: + - key: task_id + value: '{{task_id}}' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Task retrieval returns 200', function () { pm.response.to.have.status(200); }); + const body = pm.response.json(); + pm.expect(body.id).to.eql(pm.collectionVariables.get('task_id')); + pm.expect(body.project_id).to.eql(pm.collectionVariables.get('project_id')); diff --git a/postman/collections/TaskBox API/Tasks/List tasks.request.yaml b/postman/collections/TaskBox API/Tasks/List tasks.request.yaml new file mode 100644 index 0000000..c982487 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/List tasks.request.yaml @@ -0,0 +1,37 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/projects/:project_id/tasks' +order: 1000 +description: |- + Lists tasks for any project member. Supports cursor pagination plus optional `status` and `assignee_id` filters. Keep filters unchanged while following a cursor. Status values are todo, in_progress, done, archived. +pathVariables: + - key: project_id + value: '{{project_id}}' +queryParams: + - key: limit + value: '50' + description: Page size from 1 through 100. + - key: cursor + value: '{{cursor}}' + description: Opaque continuation token. + disabled: true + - key: status + value: todo + description: Optional task status filter. + disabled: true + - key: assignee_id + value: '{{member_id}}' + description: Optional exact assignee UUID filter. + disabled: true +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Task list returns 200', function () { pm.response.to.have.status(200); }); + const body = pm.response.json(); + pm.test('Task list is paginated', function () { + pm.expect(body.items).to.be.an('array'); + pm.expect(body).to.have.property('next_cursor'); + }); + if (body.next_cursor) pm.collectionVariables.set('cursor', body.next_cursor); +examples: './.resources/List tasks.resources/examples' diff --git a/postman/collections/TaskBox API/Tasks/Member can create task as editor.request.yaml b/postman/collections/TaskBox API/Tasks/Member can create task as editor.request.yaml new file mode 100644 index 0000000..7683951 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/Member can create task as editor.request.yaml @@ -0,0 +1,30 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/projects/:project_id/tasks' +order: 6000 +description: |- + Authorization example: after Update member role promotes the secondary user to editor, this request succeeds with 201. If the user remains viewer it correctly returns 403. +auth: + type: bearer + credentials: + - key: token + value: '{{member_access_token}}' +pathVariables: + - key: project_id + value: '{{project_id}}' +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "title": "Editor-created verification task", + "priority": 1 + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Editor can create a task', function () { pm.response.to.have.status(201); }); + pm.expect(pm.response.json().created_by).to.eql(pm.collectionVariables.get('member_id')); diff --git a/postman/collections/TaskBox API/Tasks/Member can read task.request.yaml b/postman/collections/TaskBox API/Tasks/Member can read task.request.yaml new file mode 100644 index 0000000..777d540 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/Member can read task.request.yaml @@ -0,0 +1,19 @@ +$kind: http-request +method: GET +url: '{{base_url}}/api/v1/tasks/:task_id' +order: 5000 +description: |- + Authorization example: a project viewer/editor can read a task. Overrides folder auth with the member token. +auth: + type: bearer + credentials: + - key: token + value: '{{member_access_token}}' +pathVariables: + - key: task_id + value: '{{task_id}}' +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Project member can read a task', function () { pm.response.to.have.status(200); }); diff --git a/postman/collections/TaskBox API/Tasks/Update task.request.yaml b/postman/collections/TaskBox API/Tasks/Update task.request.yaml new file mode 100644 index 0000000..2fb21f0 --- /dev/null +++ b/postman/collections/TaskBox API/Tasks/Update task.request.yaml @@ -0,0 +1,28 @@ +$kind: http-request +method: PATCH +url: '{{base_url}}/api/v1/tasks/:task_id' +order: 4000 +description: |- + Owner/editor partial update. Only supplied fields change. Explicit null clears description, assignee_id, or due_at. Invalid enum/priority/length values return 422. +pathVariables: + - key: task_id + value: '{{task_id}}' +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "status": "done", + "priority": 3, + "description": "Completed by the Postman workflow." + } +scripts: + - type: afterResponse + language: text/javascript + code: |- + pm.test('Task update returns 200', function () { pm.response.to.have.status(200); }); + const body = pm.response.json(); + pm.expect(body.status).to.eql('done'); + pm.expect(body.priority).to.eql(3); diff --git a/postman/collections/TaskBox API/Webhooks/.resources/Import tasks.resources/examples/202 Accepted.example.yaml b/postman/collections/TaskBox API/Webhooks/.resources/Import tasks.resources/examples/202 Accepted.example.yaml new file mode 100644 index 0000000..72ac37e --- /dev/null +++ b/postman/collections/TaskBox API/Webhooks/.resources/Import tasks.resources/examples/202 Accepted.example.yaml @@ -0,0 +1,16 @@ +$kind: http-example +name: 202 Accepted +request: + url: '{{base_url}}/api/v1/webhooks/tasks/import' + method: POST +response: + statusCode: 202 + statusText: Accepted + headers: + - key: Content-Type + value: application/json + body: + type: json + content: |- + {"event_id":"evt-postman-import-001","imported":1,"duplicate":false} +order: 1000 diff --git a/postman/collections/TaskBox API/Webhooks/.resources/Import tasks.resources/examples/409 Duplicate webhook.example.yaml b/postman/collections/TaskBox API/Webhooks/.resources/Import tasks.resources/examples/409 Duplicate webhook.example.yaml new file mode 100644 index 0000000..4c1261c --- /dev/null +++ b/postman/collections/TaskBox API/Webhooks/.resources/Import tasks.resources/examples/409 Duplicate webhook.example.yaml @@ -0,0 +1,23 @@ +$kind: http-example +name: 409 Duplicate webhook +request: + url: '{{base_url}}/api/v1/webhooks/tasks/import' + method: POST +response: + statusCode: 409 + statusText: Conflict + headers: + - key: Content-Type + value: application/problem+json + body: + type: json + content: |- + { + "type": "https://taskbox.dev/problems/duplicate_webhook", + "title": "Duplicate Webhook", + "status": 409, + "detail": "webhook event has already been received", + "instance": "http://127.0.0.1:8000/api/v1/webhooks/tasks/import", + "code": "duplicate_webhook" + } +order: 2000 diff --git a/postman/collections/TaskBox API/Webhooks/.resources/definition.yaml b/postman/collections/TaskBox API/Webhooks/.resources/definition.yaml new file mode 100644 index 0000000..e240cce --- /dev/null +++ b/postman/collections/TaskBox API/Webhooks/.resources/definition.yaml @@ -0,0 +1,12 @@ +$kind: collection +name: Webhooks +description: |- + Signed, idempotent task import. A before-request script computes HMAC-SHA256 over the exact substituted raw body using `webhook_secret` and sets `X-Webhook-Signature`. `X-Webhook-Event-ID` is the idempotency key. Reuse returns 409 `duplicate_webhook`; use a new ID only for a new business event. Verification precedes JSON parsing and authorization. An owner/editor Bearer token is used here; alternatively omit it and provide a valid `actor_id` in the signed body. + + Course mapping: this folder is the integrated TaskBox companion to `course/labs/10-webhooks-events/README.md` and the signed-import acceptance path in `course/labs/11-capstone-deployment/README.md`. Send once with a new event ID, verify imported state, then replay the same event ID to test the expected idempotency conflict. +order: 6000 +auth: + type: bearer + credentials: + - key: token + value: '{{owner_access_token}}' diff --git a/postman/collections/TaskBox API/Webhooks/Import tasks.request.yaml b/postman/collections/TaskBox API/Webhooks/Import tasks.request.yaml new file mode 100644 index 0000000..b5de6eb --- /dev/null +++ b/postman/collections/TaskBox API/Webhooks/Import tasks.request.yaml @@ -0,0 +1,35 @@ +$kind: http-request +method: POST +url: '{{base_url}}/api/v1/webhooks/tasks/import' +order: 1000 +description: |- + Imports one or more tasks from a signed payload. Required headers are a unique event ID and an HMAC-SHA256 signature over the exact raw request bytes. The script substitutes variables first, signs that exact body, and replaces the body with the signed bytes. Returns 202. Wrong signature is 401, unauthorized role 403, missing project 404, duplicate event 409, and invalid body/task values 422. +headers: + - key: Content-Type + value: application/json + - key: X-Webhook-Event-ID + value: '{{webhook_event_id}}' + - key: X-Webhook-Signature + value: '{{webhook_signature}}' +body: + type: json + content: |- + {"project_id":"{{project_id}}","actor_id":"{{owner_id}}","tasks":[{"title":"Imported vendor task","description":"Created by the signed webhook importer.","status":"todo","priority":2,"assignee_id":"{{member_id}}","due_at":"2030-02-01T12:00:00Z"}]} +scripts: + - type: beforeRequest + language: text/javascript + code: |- + const raw = pm.variables.replaceIn(pm.request.body.raw); + pm.request.body.raw = raw; + const digest = CryptoJS.HmacSHA256(raw, pm.collectionVariables.get('webhook_secret')).toString(CryptoJS.enc.Hex); + pm.variables.set('webhook_signature', `sha256=${digest}`); + - type: afterResponse + language: text/javascript + code: |- + pm.test('Webhook import returns 202', function () { pm.response.to.have.status(202); }); + const body = pm.response.json(); + pm.test('Webhook result identifies the accepted event', function () { + pm.expect(body.event_id).to.eql(pm.collectionVariables.get('webhook_event_id')); + pm.expect(body.imported).to.eql(1); + }); +examples: './.resources/Import tasks.resources/examples' diff --git a/postman/flows/New flow.flow b/postman/flows/New flow.flow new file mode 100644 index 0000000..2151788 --- /dev/null +++ b/postman/flows/New flow.flow @@ -0,0 +1,53 @@ +{ + "version": 1, + "name": "New flow", + "description": "", + "flow": { + "description": "", + "nodes": { + "7eA1dGxN": { + "type": "ev/endpoint@3", + "pos": { + "x": 0, + "y": 0 + }, + "config": { + "inputs": [], + "data": "", + "language": "json", + "allowAnyType": false + }, + "ui": { + "data": { + "triggerType": "ev/endpoint@3" + }, + "namedInputsExpanded": true + } + } + }, + "connections": {}, + "annotations": {}, + "groups": {}, + "webhook": { + "payloadContent": "body-only", + "responseType": "default" + }, + "forms": {}, + "modules": {}, + "io": {}, + "ports": {}, + "meta": {}, + "scenes": {}, + "scenarios": {}, + "config": { + "definitions": {}, + "constants": {} + } + }, + "lockbox": {}, + "info": { + "_postman_id": "cc11416a-a3c1-4b64-b9aa-9c27d9ee79ec" + }, + "_postman_exported_at": "2026-09-03T18:18:21.552Z", + "_postman_exported_using": "Postman/Local Mode" +} \ No newline at end of file diff --git a/postman/globals/workspace.globals.yaml b/postman/globals/workspace.globals.yaml new file mode 100644 index 0000000..e96c6d6 --- /dev/null +++ b/postman/globals/workspace.globals.yaml @@ -0,0 +1,2 @@ +name: Globals +values: [] diff --git a/pyproject.toml b/pyproject.toml index 9809cfd..53220e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,27 +6,34 @@ readme = "README.md" requires-python = ">=3.13,<3.15" license = { file = "LICENSE" } dependencies = [ - "flask>=3.1,<4", "fastapi>=0.115,<1", "uvicorn[standard]>=0.34,<1", - "sqlalchemy>=2.0,<3", - "alembic>=1.15,<2", "pydantic-settings>=2.7,<3", "pyjwt>=2.10,<3", "pwdlib[argon2]>=0.2,<1", "httpx>=0.28,<1", ] +[project.optional-dependencies] +# These examples are retained for the course but are not needed by the +# production TaskBox application or its container image. +legacy = ["flask>=3.1,<4"] +persistence-labs = ["sqlalchemy>=2.0,<3"] + [dependency-groups] dev = [ + "flask>=3.1,<4", + "hatchling>=1.32,<2", + "pyyaml>=6,<7", "pytest>=8.3,<9", "pytest-asyncio>=0.25,<1", "ruff>=0.9,<1", + "sqlalchemy>=2.0,<3", "mypy>=1.14,<2", ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.32,<2"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/scripts/check_openapi_contract.py b/scripts/check_openapi_contract.py index 79e37b4..617c2a7 100644 --- a/scripts/check_openapi_contract.py +++ b/scripts/check_openapi_contract.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check generated OpenAPI routes against the committed course contract.""" +"""Validate the generated TaskBox OpenAPI document against its public contract.""" from __future__ import annotations @@ -7,45 +7,138 @@ import importlib import json from pathlib import Path +from typing import Any +METHODS = {"get", "post", "put", "patch", "delete", "options", "head"} -def generated(): + +def generated() -> dict[str, Any]: for name in ("taskbox.api", "taskbox.main", "taskbox.app"): try: - mod = importlib.import_module(name) + module = importlib.import_module(name) except ImportError: continue - app = getattr(mod, "app", None) + app = getattr(module, "app", None) if app is not None and hasattr(app, "openapi"): return app.openapi() raise RuntimeError("FastAPI app not found") -def route_methods(doc: dict) -> set[tuple[str, str]]: - methods = {"get", "post", "put", "patch", "delete", "options", "head"} - return { +def resolve(document: dict[str, Any], value: dict[str, Any]) -> dict[str, Any]: + reference = value.get("$ref") + if not reference: + return value + if not reference.startswith("#/components/"): + raise ValueError(f"unsupported OpenAPI reference: {reference}") + current: Any = document + for part in reference.removeprefix("#/").split("/"): + current = current[part] + return current + + +def ref(value: dict[str, Any]) -> str | None: + return value.get("$ref") + + +def operation_errors( + contract: dict[str, Any], + generated_doc: dict[str, Any], + expected: dict[str, Any], + actual: dict[str, Any], +) -> list[str]: + errors: list[str] = [] + for key in ("operationId", "security"): + if expected.get(key) != actual.get(key): + errors.append(f"{key} differs") + + expected_request, actual_request = expected.get("requestBody"), actual.get("requestBody") + if bool(expected_request) != bool(actual_request): + errors.append("request body presence differs") + elif expected_request and actual_request: + expected_schema = expected_request["content"]["application/json"]["schema"] + actual_schema = actual_request["content"]["application/json"]["schema"] + if ref(expected_schema) != ref(actual_schema): + errors.append("request body schema differs") + + expected_headers = { + p["name"].lower() for p in expected.get("parameters", []) if p.get("in") == "header" + } + actual_headers = { + p["name"].lower() for p in actual.get("parameters", []) if p.get("in") == "header" + } + if expected_headers != actual_headers: + errors.append("header parameters differ") + + missing_statuses = set(expected["responses"]) - set(actual["responses"]) + if missing_statuses: + return errors + [f"missing response status codes: {sorted(missing_statuses)}"] + for status, expected_response in expected["responses"].items(): + expected_response = resolve(contract, expected_response) + actual_response = resolve(generated_doc, actual["responses"][status]) + if set(expected_response.get("content", {})) != set(actual_response.get("content", {})): + errors.append(f"{status} response media type differs") + continue + for media_type, expected_media in expected_response.get("content", {}).items(): + actual_media = actual_response["content"][media_type] + if ref(expected_media["schema"]) != ref(actual_media["schema"]): + errors.append(f"{status} response schema differs") + return errors + + +def validate(contract: dict[str, Any], actual: dict[str, Any]) -> list[str]: + errors: list[str] = [] + for key in ("title", "version", "description"): + if contract["info"].get(key) != actual["info"].get(key): + errors.append(f"info.{key} differs") + if contract.get("servers") != actual.get("servers"): + errors.append("servers differ") + if contract["components"]["securitySchemes"] != actual["components"]["securitySchemes"]: + errors.append("security schemes differ") + + expected_paths = { (path, method) - for path, item in doc.get("paths", {}).items() + for path, item in contract["paths"].items() for method in item - if method in methods + if method in METHODS } + actual_paths = { + (path, method) + for path, item in actual["paths"].items() + for method in item + if method in METHODS + } + if expected_paths != actual_paths: + errors.append("path and method set differs") + for path, method in sorted(expected_paths & actual_paths): + for error in operation_errors( + contract, actual, contract["paths"][path][method], actual["paths"][path][method] + ): + errors.append(f"{method.upper()} {path}: {error}") + + for name, expected_schema in contract["components"]["schemas"].items(): + actual_schema = actual["components"].get("schemas", {}).get(name) + if actual_schema is None: + errors.append(f"component schema {name} is missing") + elif not set(expected_schema.get("required", [])).issubset( + actual_schema.get("required", []) + ): + errors.append(f"component schema {name} is missing required fields") + return errors def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--contract", default="contracts/taskbox.openapi.json") args = parser.parse_args() - expected = json.loads(Path(args.contract).read_text(encoding="utf-8")) - actual = generated() - missing = sorted(route_methods(expected) - route_methods(actual)) - extra = sorted(route_methods(actual) - route_methods(expected)) - if missing or extra: - if missing: - print("Missing contract routes:", ", ".join(f"{m.upper()} {p}" for p, m in missing)) - if extra: - print("Unexpected routes:", ", ".join(f"{m.upper()} {p}" for p, m in extra)) + contract = json.loads(Path(args.contract).read_text(encoding="utf-8")) + errors = validate(contract, generated()) + if errors: + print("OpenAPI contract mismatch:\n- " + "\n- ".join(errors)) return 1 - print(f"OpenAPI contract OK ({len(route_methods(expected))} operations)") + operation_count = sum( + len([method for method in item if method in METHODS]) for item in contract["paths"].values() + ) + print(f"OpenAPI contract OK ({operation_count} operations)") return 0 diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py index dbad640..af8fd46 100644 --- a/scripts/export_openapi.py +++ b/scripts/export_openapi.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Export the TaskBox application's generated OpenAPI document. -Usage: python scripts/export_openapi.py [output.json] +Usage: python scripts/export_openapi.py OUTPUT.json The app can expose either ``taskbox.api:app`` or ``taskbox.main:app``. """ @@ -26,7 +26,10 @@ def find_app(): def main() -> int: - destination = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("contracts/taskbox.openapi.json") + if len(sys.argv) != 2: + print("usage: python scripts/export_openapi.py OUTPUT.json", file=sys.stderr) + return 2 + destination = Path(sys.argv[1]) document = find_app().openapi() destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(json.dumps(document, indent=2, sort_keys=False) + "\n", encoding="utf-8") diff --git a/scripts/validate_course_map.py b/scripts/validate_course_map.py index 335069e..9ecfe06 100644 --- a/scripts/validate_course_map.py +++ b/scripts/validate_course_map.py @@ -1,24 +1,102 @@ #!/usr/bin/env python3 -"""Validate course-map invariants without requiring PyYAML.""" +"""Validate the declarative course-map invariants.""" from __future__ import annotations -import re +from collections.abc import Mapping from pathlib import Path +import yaml + +COURSE_MAP_PATH = Path("course/course-map.yml") +LABS_ROOT = Path("course/labs") + + +def _mapping(value: object, label: str) -> Mapping[str, object]: + if not isinstance(value, Mapping): + raise ValueError(f"{label} must be a mapping") + if not all(isinstance(key, str) for key in value): + raise ValueError(f"{label} must use string keys") + return value + + +def _integer(mapping: Mapping[str, object], key: str, label: str) -> int: + value = mapping.get(key) + if type(value) is not int: + raise ValueError(f"{label}.{key} must be an integer") + return value + + +def _string_list(mapping: Mapping[str, object], key: str, label: str) -> list[str]: + value = mapping.get(key) + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + raise ValueError(f"{label}.{key} must be a non-empty list of strings") + return value + + +def load_course_map(path: Path = COURSE_MAP_PATH) -> Mapping[str, object]: + try: + return _mapping(yaml.safe_load(path.read_text(encoding="utf-8")), "course map") + except yaml.YAMLError as exc: + raise ValueError(f"invalid YAML: {exc}") from exc + + +def validate_course_map( + course_map: Mapping[str, object], labs_root: Path = LABS_ROOT +) -> tuple[int, int, int]: + modules_value = course_map.get("modules") + if not isinstance(modules_value, list) or not modules_value: + raise ValueError("modules must be a non-empty list") + + modules = [_mapping(module, f"modules[{index}]") for index, module in enumerate(modules_value)] + declared_hours = _integer(course_map, "duration_hours", "course map") + module_hours = sum( + _integer(module, "hours", f"modules[{index}]") for index, module in enumerate(modules) + ) + + completion = _mapping(course_map.get("course_completion"), "course_completion") + required_hours = _integer(completion, "required_hours", "course_completion") + if module_hours != declared_hours or required_hours != declared_hours: + raise ValueError( + "course hours disagree " + f"(modules={module_hours}, duration={declared_hours}, required={required_hours})" + ) + + mapped_labs = [ + lab + for index, module in enumerate(modules) + for lab in _string_list(module, "labs", f"modules[{index}]") + ] + required_labs = _string_list(completion, "required_labs", "course_completion") + if mapped_labs != required_labs: + raise ValueError(f"required labs must match module order: {required_labs} != {mapped_labs}") + if len(set(mapped_labs)) != len(mapped_labs): + raise ValueError("each lab may appear only once") + + first_module = modules[0] + if ( + first_module.get("id") != "postman-prerequisite" + or first_module.get("prerequisite") is not True + or mapped_labs[0] != "00-postman-prerequisite" + ): + raise ValueError("Postman prerequisite must be first and marked prerequisite: true") + + for lab in mapped_labs: + guide = labs_root / lab / "README.md" + if not guide.is_file(): + raise ValueError(f"missing lab guide: {guide}") + + return len(modules), module_hours, len(mapped_labs) + def main() -> int: - text = Path("course/course-map.yml").read_text(encoding="utf-8") - hours = [int(value) for value in re.findall(r"^ hours: (\d+)$", text, re.MULTILINE)] - if not hours or sum(hours) != 40: - print(f"course hours must total 40 (found {sum(hours)})") + try: + module_count, hours, lab_count = validate_course_map(load_course_map()) + except (OSError, ValueError) as exc: + print(f"course map invalid: {exc}") return 1 - for lab in re.findall(r"labs: \[([^]]+)\]", text): - for name in (part.strip() for part in lab.split(",")): - if not Path("course/labs", name).is_dir(): - print(f"missing lab directory: {name}") - return 1 - print(f"course map OK ({len(hours)} modules, {sum(hours)} hours)") + + print(f"course map OK ({module_count} modules, {hours} hours, {lab_count} labs)") return 0 diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 9d54f3a..1c28313 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -23,6 +23,23 @@ export default defineConfig({ { label: 'Environment setup', slug: 'setup' }, ], }, + { + label: 'Course modules', + items: [ + { label: 'Prerequisite 00 · Postman foundations', slug: 'modules/00-postman-prerequisite' }, + { label: '01 · HTTP and API design', slug: 'modules/01-http-api-design' }, + { label: '02 · FastAPI foundations', slug: 'modules/02-fastapi-foundations' }, + { label: '03 · Resource CRUD', slug: 'modules/03-resource-crud' }, + { label: '04 · Testing and clients', slug: 'modules/04-testing-clients' }, + { label: '05 · Authentication and security', slug: 'modules/05-auth-security' }, + { label: '06 · Persistence', slug: 'modules/06-persistence' }, + { label: '07 · Operations', slug: 'modules/07-operations' }, + { label: '08 · GraphQL and gRPC', slug: 'modules/08-graphql-grpc' }, + { label: '09 · Realtime APIs', slug: 'modules/09-realtime' }, + { label: '10 · Webhooks and events', slug: 'modules/10-webhooks-events' }, + { label: '11 · Capstone deployment', slug: 'modules/11-capstone-deployment' }, + ], + }, { label: 'TaskBox API', items: [ @@ -40,22 +57,6 @@ export default defineConfig({ { label: 'PostgreSQL with Docker', slug: 'persistence/postgresql' }, ], }, - { - label: 'Course modules', - items: [ - { label: '01 · HTTP and API design', slug: 'modules/01-http-api-design' }, - { label: '02 · FastAPI foundations', slug: 'modules/02-fastapi-foundations' }, - { label: '03 · Resource CRUD', slug: 'modules/03-resource-crud' }, - { label: '04 · Testing and clients', slug: 'modules/04-testing-clients' }, - { label: '05 · Authentication and security', slug: 'modules/05-auth-security' }, - { label: '06 · Persistence', slug: 'modules/06-persistence' }, - { label: '07 · Operations', slug: 'modules/07-operations' }, - { label: '08 · GraphQL and gRPC', slug: 'modules/08-graphql-grpc' }, - { label: '09 · Realtime APIs', slug: 'modules/09-realtime' }, - { label: '10 · Webhooks and events', slug: 'modules/10-webhooks-events' }, - { label: '11 · Capstone deployment', slug: 'modules/11-capstone-deployment' }, - ], - }, ], }), ], diff --git a/site/src/content/docs/index.md b/site/src/content/docs/index.md index f4b1fff..058e671 100644 --- a/site/src/content/docs/index.md +++ b/site/src/content/docs/index.md @@ -6,16 +6,16 @@ hero: tagline: Python 3.13 · FastAPI · SQLite · PostgreSQL actions: - text: Start the course - link: /setup/ + link: ./setup/ icon: right-arrow - text: See the TaskBox API - link: /taskbox/domain-and-routes/ + link: ./taskbox/domain-and-routes/ icon: external --- TaskBox is a small, complete API that gives you room to practice the decisions that matter in real services: authentication, authorization, persistence, pagination, and webhooks. -Start with [environment setup](/setup/), then follow the chapters in order. The +Start with [environment setup](./setup/), complete [Prerequisite 00: Postman foundations](./modules/00-postman-prerequisite/), then follow the numbered chapters in order. The required prerequisite appears before Lab 01 and brings the complete course to 43 hours. The course uses a deliberately small reference application so each boundary is inspectable: HTTP contracts at the edge, domain rules in the middle, and replaceable adapters at the infrastructure boundary. diff --git a/site/src/content/docs/modules/00-postman-prerequisite.md b/site/src/content/docs/modules/00-postman-prerequisite.md new file mode 100644 index 0000000..0f08d7d --- /dev/null +++ b/site/src/content/docs/modules/00-postman-prerequisite.md @@ -0,0 +1,48 @@ +--- +title: "Prerequisite 00: Postman foundations" +description: Learn the local TaskBox collection workflow before Lab 01. +--- + +Complete this required three-hour prerequisite before Module 1. It uses the repository's existing file-backed **TaskBox API** collection and does not change request behavior. + +## Learning objectives + +You will learn to navigate the local workspace and collection, send and inspect requests, resolve variable scopes, protect secrets, understand authorization inheritance, read scripts/tests, compare examples with live responses, and run dependent requests in a safe order. + +## Setup + +From the repository root: + +```bash +uv sync --all-groups --frozen +uv run uvicorn taskbox.main:app --reload +``` + +TaskBox runs at `http://127.0.0.1:8000`. Open **TaskBox API > Health > Health check** in Postman, send it, and verify the 200 status, JSON body, headers, timing, and passing test results. + +## Practical path + +1. Compare the Postman sidebar with `postman/collections/TaskBox API/`: collection and folder metadata live in `.resources/definition.yaml`, requests in `*.request.yaml`, and examples in request-specific `.resources` directories. +2. Inspect `{{base_url}}` and the owner/member variables. Use environment values only for machine/deployment overrides; avoid globals and avoid duplicating all collection values. +3. Keep real passwords, JWTs, webhook secrets, and API keys in local/session values, never committed shared files. Repository defaults are disposable local examples only. +4. Compare request-level `noauth`, owner/member Bearer auth, and the inherited owner auth on Projects, Members, Tasks, and Webhooks. +5. Read collection and request after-response tests. Observe how registration/token/project/task scripts capture IDs and tokens. Read the webhook before-request HMAC script. +6. Compare saved examples with live responses. Examples are static documentation; live sends contact TaskBox, run scripts, and can update workflow variables. +7. Bootstrap owner and member identities, create a project, configure membership, create tasks, and import a signed event. Preserve dependency order and run destructive cleanup last. +8. Read collection/folder descriptions when each later course module directs you to the TaskBox companion requests. + +## Required lab + +The complete lesson—including objectives, scope table, hands-on exercises, expected outcomes, troubleshooting, security guidance, knowledge checks, and completion checklist—is in: + +`course/labs/00-postman-prerequisite/README.md` + +Do every exercise there before opening `course/labs/01-http-api-design/README.md`. + +## Expected outcome + +You can explain what a request resolves and sends, where its authorization comes from, which script updates downstream state, why an example is not a live result, and which request must run next. You can diagnose 401, 403, 404, 409, and 422 outcomes without weakening existing assertions. + +## Security checkpoint + +Do not commit populated tokens or real secrets, expose the development service, reset a database you do not own, or paste credentials into saved headers/examples. Review scripts before running them and use a disposable local database and identities. diff --git a/site/src/content/docs/modules/01-http-api-design.md b/site/src/content/docs/modules/01-http-api-design.md index 5c62211..358bbbe 100644 --- a/site/src/content/docs/modules/01-http-api-design.md +++ b/site/src/content/docs/modules/01-http-api-design.md @@ -3,6 +3,8 @@ title: "Module 1: HTTP and API design" description: Turn product behavior into a predictable HTTP contract. --- +Complete [Prerequisite 00: Postman foundations](../00-postman-prerequisite/) before beginning this module. + An API is a contract between independently changing programs. Good API design starts before framework code: identify the resources, decide which state transitions are allowed, and describe the success and failure responses a client can depend on. @@ -56,7 +58,7 @@ is for humans: ```json { - "type": "https://taskbox.dev/problems/forbidden", + "type": "http://127.0.0.1:8000/problems/forbidden", "title": "Forbidden", "status": 403, "detail": "your project role cannot perform this action", diff --git a/site/src/content/docs/modules/06-persistence.md b/site/src/content/docs/modules/06-persistence.md index 17cd78a..6b78d22 100644 --- a/site/src/content/docs/modules/06-persistence.md +++ b/site/src/content/docs/modules/06-persistence.md @@ -37,7 +37,7 @@ def create_task(session: Session, data: TaskCreate) -> Task: return to_domain(row) ``` -Do not treat `create_all` as a migration system. It cannot describe renames, data backfills, or rollback intent. In production, generate and review Alembic revisions, run them as a release step before traffic, and record the schema version. Design migrations to be compatible with the code versions that may overlap during rollout. +Do not treat `create_all` as a migration system. It cannot describe renames, data backfills, or rollback intent. In production, maintain and review versioned schema changes, run them as a release step before traffic, and record the schema version. This repository intentionally ships no migration CLI or migration history. Design schema changes to be compatible with the code versions that may overlap during rollout. ## SQLite first, PostgreSQL next diff --git a/site/src/content/docs/modules/07-operations.md b/site/src/content/docs/modules/07-operations.md index 87e36e4..91f5a47 100644 --- a/site/src/content/docs/modules/07-operations.md +++ b/site/src/content/docs/modules/07-operations.md @@ -41,7 +41,7 @@ Use JSON logs in deployment so a collector can filter by request ID. Redact Auth Runtime configuration supplies the database URL, JWT secret, and other secrets. Validate required values at startup and keep safe defaults limited to local development. A graceful shutdown stops accepting new work, lets in-flight requests finish within a deadline, closes database pools, and then exits. Make timeout behavior explicit: work that cannot finish should be retried safely by its caller or queue. -The provided Compose file demonstrates a dependency-aware startup. PostgreSQL has a healthcheck; the API waits for the database to become healthy before starting. This avoids a race, but readiness must still test the live dependency because a healthy database can fail later. Use migrations as a separate, observable release step rather than hiding schema creation in application startup. +The provided Compose file demonstrates a dependency-aware startup. PostgreSQL has a healthcheck; the API waits for the database to become healthy before starting. This avoids a race, but readiness must still test the live dependency because a healthy database can fail later. Use schema changes as a separate, observable release step rather than hiding schema creation in application startup. The SQLite reference app bootstraps a local development file from reference DDL; it has no versioned migration CLI or migration history. ## Practice and verification diff --git a/site/src/content/docs/modules/10-webhooks-events.md b/site/src/content/docs/modules/10-webhooks-events.md index e4ca360..16f5180 100644 --- a/site/src/content/docs/modules/10-webhooks-events.md +++ b/site/src/content/docs/modules/10-webhooks-events.md @@ -41,7 +41,7 @@ Do not hold the HTTP request open while making an email call or importing a larg ## Practice and verification -Run the signer in [course/examples/webhooks/sign.py](https://github.com/ialimustufa/API/blob/main/course/examples/webhooks/sign.py) to produce a development header and body, then send them to the Lab 10 solution. The [Lab 10 README and source](https://github.com/ialimustufa/API/tree/main/course/labs/10-webhooks-events) describe the expected contract. Test a valid event, a changed body, wrong secret, stale timestamp, malformed header, missing ID, and the same valid event twice. The duplicate should be acknowledged safely and should create only one side effect. +Run the timestamped Lab 10 signer in [course/examples/webhooks/sign.py](https://github.com/ialimustufa/API/blob/main/course/examples/webhooks/sign.py) only against the Lab 10 solution. It emits `X-TaskBox-Signature: t=...,v1=...`, which is intentionally different from the TaskBox reference API. For TaskBox, use [course/examples/webhooks/sign_taskbox.py](https://github.com/ialimustufa/API/blob/main/course/examples/webhooks/sign_taskbox.py); it emits the event-ID and raw-body HMAC headers documented on the TaskBox signed-webhooks page. Test a valid event, a changed body, wrong secret, stale timestamp, malformed header, missing ID, and the same valid event twice. The duplicate should be acknowledged safely and should create only one side effect. Exercise: add a durable event status (`received`, `processed`, `failed`) and a retry worker with bounded attempts. Document whether a permanently failed event can be replayed manually and how an operator proves that replay is safe. diff --git a/site/src/content/docs/modules/11-capstone-deployment.md b/site/src/content/docs/modules/11-capstone-deployment.md index fb91c2e..9efa8fb 100644 --- a/site/src/content/docs/modules/11-capstone-deployment.md +++ b/site/src/content/docs/modules/11-capstone-deployment.md @@ -32,7 +32,7 @@ The release sequence should be explicit: build -> backup/check -> migrate -> start -> readiness -> smoke test -> traffic ``` -Run reviewed Alembic migrations before application traffic. Prefer additive, backward-compatible schema changes when old and new instances overlap. Define the rollback condition, who makes the decision, and whether rollback means reverting code, restoring data, or applying a compensating migration. A backup is useful only if restoration has been rehearsed. +Run reviewed, versioned schema changes before application traffic. Prefer additive, backward-compatible changes when old and new instances overlap. Define the rollback condition, who makes the decision, and whether rollback means reverting code, restoring data, or applying a compensating schema change. A backup is useful only if restoration has been rehearsed. ## Verification checklist diff --git a/site/src/content/docs/persistence/postgresql.md b/site/src/content/docs/persistence/postgresql.md index ad330a7..8907a75 100644 --- a/site/src/content/docs/persistence/postgresql.md +++ b/site/src/content/docs/persistence/postgresql.md @@ -42,14 +42,14 @@ It starts only `app`, publishes `http://127.0.0.1:8000`, sets `TASKBOX_DATABASE_ ## Migration thinking -Apply the portable schema in `migrations/001_initial.sql` (or the lab's migration solution) to the target PostgreSQL database before traffic. Do not copy a SQLite file into PostgreSQL. Run migrations, verify constraints and indexes, then start the app: database reachable → migration succeeds → API starts → readiness passes. +Use a reviewed, versioned PostgreSQL schema-change process before traffic. The reference app's SQLite bootstrap DDL is not a deployable migration history and must not be copied into PostgreSQL as a deployment step. Apply the schema change, verify constraints and indexes, then start the app: database reachable → schema change succeeds → API starts → readiness passes. The initial SQL uses portable `TEXT` for UUID strings and ISO-8601 UTC timestamps, keeping domain behavior comparable. Still check engine-specific behavior: foreign-key enforcement, uniqueness, transaction isolation, timestamp ordering, and cursor query plans. Compatibility means preserving the application contract, not pretending engines are identical. ## Exercises 1. Run `docker compose config` and annotate the dependency graph. Why does `depends_on` use `condition: service_healthy`? -2. Break the password or host in `TASKBOX_DATABASE_URL`, restart the API, and distinguish DNS, authentication, and migration/schema failures from logs. +2. Break the password or host in `TASKBOX_DATABASE_URL`, restart the API, and distinguish DNS, authentication, and schema failures from logs. 3. Apply the schema, register a user, create a project and task, then restart only the API. Confirm rows remain while `db` runs. 4. Compare cursor-paginated task queries in SQLite and PostgreSQL. Keep route responses, authorization, and cursor shape unchanged. 5. Interrupt a multi-write operation and inspect for partial state. Write a rollback and retry note. @@ -64,7 +64,7 @@ The initial SQL uses portable `TEXT` for UUID strings and ISO-8601 UTC timestamp **Data disappeared.** Lab 07 has no persistent volume. Root Compose data is in `taskbox_data`; avoid `down -v` unless deletion is intentional. -**Readiness fails.** Inspect API logs, verify URL and credentials, and confirm migration ran against database `taskbox`. Process health does not prove dependency readiness. +**Readiness fails.** Inspect API logs, verify URL and credentials, and confirm the expected schema is present in database `taskbox`. Process health does not prove dependency readiness. ## Outcome diff --git a/site/src/content/docs/persistence/sqlite-first.md b/site/src/content/docs/persistence/sqlite-first.md index e692270..57c2792 100644 --- a/site/src/content/docs/persistence/sqlite-first.md +++ b/site/src/content/docs/persistence/sqlite-first.md @@ -39,13 +39,13 @@ This gives the course a concrete test: changing the persistence adapter must not ## Schema, constraints, and migrations -The local adapter executes its embedded schema on startup with `CREATE TABLE IF NOT EXISTS`. That is convenient reference-app bootstrap, not a migration history. The portable schema is also checked in as `migrations/001_initial.sql`; it defines users, projects, memberships, tasks, webhook receipts, foreign keys, uniqueness, status checks, and cursor indexes. Read both when changing persistence so the adapter stays aligned with the migration source. +The local adapter executes the packaged `src/taskbox/adapters/reference_schema.sql` with `CREATE TABLE IF NOT EXISTS` on startup. This is convenient reference-app bootstrap DDL, not a migration history. It defines users, projects, memberships, tasks, webhook receipts, foreign keys, uniqueness, status checks, and cursor indexes. It is the sole SQLite schema source; do not duplicate it in application code. For a real schema change, write a forward migration rather than editing an existing migration or relying on `create_all`. Plan old and new shapes, backfill existing rows, add constraints after data is valid, and document rollback (or why it is irreversible). Apply it to a disposable copy first and test both empty and populated databases. ## Transactions and failure behavior -Use a unit of work around a multi-write operation. Normal exit commits; an exception rolls back. A failed task creation must not leave a task without its project relationship, and webhook import must not process the same event twice. Reads should not mutate state. SQLite serializes the adapter's critical section, but that does not remove the need for a clear commit boundary. +Use a unit of work around a multi-write operation. Normal exit commits; an exception rolls back. A failed task creation must not leave a task without its project relationship, and webhook import must not process the same event twice. Reads should not mutate state. The reference adapter deliberately uses one shared SQLite connection protected by a global `RLock`; this serializes requests and is suitable only for the local course application. The unit of work owns explicit `BEGIN`/commit/rollback boundaries, while simple reads and readiness checks also acquire the same lock. A production adapter should use a database-appropriate connection/session pool instead. ```bash sqlite3 taskbox.db '.tables' @@ -60,7 +60,7 @@ If `sqlite3` is unavailable, inspect with the Python standard library or reposit 1. Create two tasks, restart Uvicorn, and confirm both remain. Record the URL and file location. 2. Attempt a task with a missing project. Explain which foreign key protects the invariant. 3. Write an operation that creates a membership and related record in one unit of work. Force an exception between writes and prove neither row remains. -4. Map each index in `migrations/001_initial.sql` to a list query. Explain why `(created_at, id)` is a stable cursor tie-breaker. +4. Map each index in `src/taskbox/adapters/reference_schema.sql` to a list query. Explain why `(created_at, id)` is a stable cursor tie-breaker. The implementation exercise in `course/labs/06-persistence/` intentionally starts incomplete. Keep starter code incomplete when teaching; validate the solution against persistence, rollback, uniqueness, and cursor tests. diff --git a/site/src/content/docs/setup.md b/site/src/content/docs/setup.md index 1d24528..ee6017d 100644 --- a/site/src/content/docs/setup.md +++ b/site/src/content/docs/setup.md @@ -48,6 +48,16 @@ uv run pytest Then register a user and exercise the route examples in the TaskBox chapters. Restart the server and confirm SQLite data remains. For clean state, remove only a disposable lesson database you intentionally created. +## Begin the course + +After setup, complete [Prerequisite 00: Postman foundations](../modules/00-postman-prerequisite/) and its practical lab at `course/labs/00-postman-prerequisite/README.md`. It is a required three-hour part of the course and must appear before Lab 01. Use the local file-backed `TaskBox API` collection under `postman/collections/TaskBox API/`; do not create a duplicate collection. + +## Reference material + +Work through the prerequisite and numbered course modules in order. When you need implementation details for the course application, start with the [TaskBox API domain and routes](../taskbox/domain-and-routes/), then use the neighboring reference pages for authentication, project roles, cursor pagination, and signed webhooks. + +For storage guidance, begin with [SQLite-first persistence](../persistence/sqlite-first/). Use [PostgreSQL with Docker](../persistence/postgresql/) when you reach the database transition lab. + ## Documentation site The site is in `site/`. Keep the committed npm lockfile in sync: @@ -58,7 +68,7 @@ npm ci npm run dev ``` -Open `http://localhost:4321/`. Local links use root `/`. The production GitHub Pages build is beneath `/API/`; a page linked as `/setup/` locally is reached as `/API/setup/` when deployed. Do not hard-code `/API/` into lesson links, because that breaks local preview. +Open `http://localhost:4321/`. Use relative links for internal documentation routes. For example, `./setup/` from the home page resolves to `/setup/` locally and `/API/setup/` in production. Avoid both root-absolute `/setup/` links and hard-coded `/API/setup/` links so local preview and GitHub Pages use the same source. Build before sharing documentation changes: diff --git a/site/src/content/docs/taskbox/authentication.md b/site/src/content/docs/taskbox/authentication.md index 0688f38..a4d0514 100644 --- a/site/src/content/docs/taskbox/authentication.md +++ b/site/src/content/docs/taskbox/authentication.md @@ -17,6 +17,8 @@ curl -i -X POST http://127.0.0.1:8000/api/v1/auth/register \ Email is lowercased by validation. Passwords shorter than eight characters fail with `422`; a repeated email is `409` with `code: "conflict"`. The response is `201` and contains a UUID, status (`active`), and timestamps, but no password hash. +The explicit `409` is intentional for the course: it makes the uniqueness constraint observable, but it also reveals whether an email is registered. A public self-service registration flow should avoid treating that response as private-account protection; use rate limits and email verification, or return an indistinguishable acknowledgement when enumeration resistance is required. + The SQLite adapter stores the Argon2 hash. This is intentionally a port-and-adapter boundary: replacing SQLite with PostgreSQL does not require changing the HTTP route or domain model. In a real service, add rate limits, email verification, breached-password checks, and an account-recovery flow around this minimal lesson implementation. ## Issue a token @@ -35,6 +37,8 @@ The response shape is: {"access_token":"eyJ...","token_type":"bearer","expires_in":3600} ``` +Unknown email, disabled account, and wrong password all receive the same `401` problem detail. TaskBox performs a password verification against a dummy Argon2 hash when the email is unknown, so that lookup branch does not skip the expensive verification work. This reduces a timing signal; it does not replace rate limiting and other account-abuse controls. + The issuer signs an `HS256` token containing `sub` (the user UUID), `iat`, `exp`, and `iss: "taskbox"`. The default lifetime is 3,600 seconds and can be changed with `TASKBOX_JWT_EXPIRES`. Do not put secrets, passwords, or mutable authorization decisions in claims. TaskBox checks membership in the database for each project operation, so role changes take effect without waiting for a token refresh. ## Send and validate it @@ -51,14 +55,14 @@ curl -sS http://127.0.0.1:8000/api/v1/projects \ No credential, a forged token, a token signed with the wrong algorithm, an expired token, or a token for a disabled user produces `401` with `WWW-Authenticate: Bearer`. An example problem document is: ```json -{"type":"https://taskbox.dev/problems/authentication_required","title":"Authentication Required","status":401,"detail":"invalid or expired token","instance":"...","code":"authentication_required"} +{"type":"http://127.0.0.1:8000/problems/authentication_required","title":"Authentication Required","status":401,"detail":"invalid or expired token","instance":"...","code":"authentication_required"} ``` Validation errors are different: a malformed request body is `422` and includes `errors` entries with locations such as `body.password`. ## Configuration and deployment -Set a long, random `TASKBOX_JWT_SECRET` in the deployment environment. The built-in `dev-only-change-me` fallback is for local learning only. Keep configuration out of source control, rotate secrets deliberately, use HTTPS, and avoid logging full Authorization headers. If you need immediate revocation, add a token version or denylist; short expiry alone does not revoke an already-issued token. +Set long, random `TASKBOX_JWT_SECRET` and `TASKBOX_WEBHOOK_SECRET` values in the deployment environment. `TASKBOX_ENV` defaults to `development`, which permits the committed placeholder values only for disposable local instruction. In `production`, `staging`, or any other non-local value, TaskBox rejects empty values and known development placeholders at startup. Keep configuration out of source control, rotate secrets deliberately, use HTTPS, and avoid logging full Authorization headers. If you need immediate revocation, add a token version or denylist; short expiry alone does not revoke an already-issued token. ## Exercise diff --git a/site/src/content/docs/taskbox/cursor-pagination.md b/site/src/content/docs/taskbox/cursor-pagination.md index 7581460..45103d0 100644 --- a/site/src/content/docs/taskbox/cursor-pagination.md +++ b/site/src/content/docs/taskbox/cursor-pagination.md @@ -50,7 +50,7 @@ Task lists support `status` (`todo`, `in_progress`, `done`, `archived`) and `ass `limit` is validated by FastAPI: values below 1 or above 100 produce `422`. A malformed or undecodable cursor produces `400`: ```json -{"type":"https://taskbox.dev/problems/invalid_cursor","title":"Invalid Cursor","status":400,"detail":"cursor is invalid","instance":"...","code":"invalid_cursor"} +{"type":"http://127.0.0.1:8000/problems/invalid_cursor","title":"Invalid Cursor","status":400,"detail":"cursor is invalid","instance":"...","code":"invalid_cursor"} ``` Handle this as a restartable client error: discard the cursor and fetch the first page, or ask the user to refresh. diff --git a/site/src/content/docs/taskbox/domain-and-routes.md b/site/src/content/docs/taskbox/domain-and-routes.md index 08fbf3f..117f3b6 100644 --- a/site/src/content/docs/taskbox/domain-and-routes.md +++ b/site/src/content/docs/taskbox/domain-and-routes.md @@ -65,7 +65,7 @@ Task creation accepts `title` (1–240 characters), optional `description` (up t Expected domain failures use RFC 9457-style `application/problem+json`. For example, an unauthenticated request returns `401` and a Bearer challenge: ```json -{"type":"https://taskbox.dev/problems/authentication_required","title":"Authentication Required","status":401,"detail":"authentication required","instance":"http://127.0.0.1:8000/api/v1/me","code":"authentication_required"} +{"type":"http://127.0.0.1:8000/problems/authentication_required","title":"Authentication Required","status":401,"detail":"authentication required","instance":"http://127.0.0.1:8000/api/v1/me","code":"authentication_required"} ``` Malformed JSON fields are `422` with an `errors` array; missing resources are `404`; insufficient membership or role is `403`; duplicate email or membership is `409`. Clients should branch on `code`, not scrape `detail` text. diff --git a/site/src/content/docs/taskbox/projects-and-roles.md b/site/src/content/docs/taskbox/projects-and-roles.md index d67b21e..855c98f 100644 --- a/site/src/content/docs/taskbox/projects-and-roles.md +++ b/site/src/content/docs/taskbox/projects-and-roles.md @@ -50,7 +50,7 @@ curl -X POST "http://127.0.0.1:8000/api/v1/projects/$PROJECT_ID/tasks" \ A viewer can list the same project and tasks, but the identical create request returns `403`: ```json -{"type":"https://taskbox.dev/problems/forbidden","title":"Forbidden","status":403,"detail":"your project role cannot perform this action","instance":"...","code":"forbidden"} +{"type":"http://127.0.0.1:8000/problems/forbidden","title":"Forbidden","status":403,"detail":"your project role cannot perform this action","instance":"...","code":"forbidden"} ``` Task updates and deletes use `/api/v1/tasks/{task_id}` rather than nesting the project ID. That is safe because the service loads the task, obtains its `project_id`, and performs the same membership check. Never implement this route by checking only that the task ID exists. diff --git a/site/src/content/docs/taskbox/signed-webhooks.md b/site/src/content/docs/taskbox/signed-webhooks.md index 60c6c2e..7907fc1 100644 --- a/site/src/content/docs/taskbox/signed-webhooks.md +++ b/site/src/content/docs/taskbox/signed-webhooks.md @@ -30,17 +30,21 @@ curl -i -X POST http://127.0.0.1:8000/api/v1/webhooks/tasks/import \ The successful response is `202`: ```json -{"event_id":"vendor-2025-0001","imported":1,"duplicate":false} +{"event_id":"vendor-2025-0001","imported":1} ``` The optional Bearer token matters. If no token is supplied, the service can use `actor_id` from the verified body; without either actor, it returns `401`. The actor must be an owner or editor of the target project. A viewer is authenticated but receives `403`. +### Capstone threat-model note + +TaskBox uses one shared development secret, so a valid signature proves only that a sender knows that secret; it does not establish a distinct sender identity. In particular, an unauthenticated but correctly signed payload can name any project owner or editor in `actor_id`. This is acceptable only for the controlled lab. A production receiver should either require Bearer authentication for the acting user or map a per-sender secret/key identity to an allowed actor or project scope. Do not treat `actor_id` from the body as independent authorization. + ## Verification and idempotency TaskBox uses constant-time HMAC comparison and strips only the optional `sha256=` prefix. A wrong secret, altered body, or malformed signature returns `401` with `code: "invalid_webhook_signature"`: ```json -{"type":"https://taskbox.dev/problems/invalid_webhook_signature","title":"Invalid Webhook Signature","status":401,"detail":"webhook signature is invalid","instance":"...","code":"invalid_webhook_signature"} +{"type":"http://127.0.0.1:8000/problems/invalid_webhook_signature","title":"Invalid Webhook Signature","status":401,"detail":"webhook signature is invalid","instance":"...","code":"invalid_webhook_signature"} ``` Only after verification does the service decode JSON. Invalid JSON or a missing project/tasks list is `422` with `code: "validation_error"`; a nonexistent project is `404`. Task field validation (for example, priority outside 0–4) also fails as a domain validation error. @@ -48,7 +52,7 @@ Only after verification does the service decode JSON. Invalid JSON or a missing After authorization, the service creates a `WebhookReceipt` containing the event ID, signature, SHA-256 payload hash, and processing timestamps. The event ID is unique. Replaying the same event—even with a different payload—returns `409` and `code: "duplicate_webhook"`: ```json -{"type":"https://taskbox.dev/problems/duplicate_webhook","title":"Duplicate Webhook","status":409,"detail":"webhook event has already been received","instance":"...","code":"duplicate_webhook"} +{"type":"http://127.0.0.1:8000/problems/duplicate_webhook","title":"Duplicate Webhook","status":409,"detail":"webhook event has already been received","instance":"...","code":"duplicate_webhook"} ``` The receipt and imported tasks are committed in one unit of work. If task validation fails midway, the transaction must roll back so a later corrected retry is not incorrectly blocked by a receipt. The receipt status model (`received`, `processed`, `failed`) provides an audit trail for production retry tooling. diff --git a/migrations/001_initial.sql b/src/taskbox/adapters/reference_schema.sql similarity index 86% rename from migrations/001_initial.sql rename to src/taskbox/adapters/reference_schema.sql index 4119f5c..daf9cff 100644 --- a/migrations/001_initial.sql +++ b/src/taskbox/adapters/reference_schema.sql @@ -1,9 +1,8 @@ --- TaskBox initial schema. +-- TaskBox reference SQLite bootstrap schema. -- --- The types intentionally use portable SQL (TEXT for UUIDs and timestamps), --- so this migration can be exercised with SQLite in the early labs and run by --- PostgreSQL in the production lab. Application code stores ISO-8601 UTC --- timestamps and UUID strings in both databases. +-- This is deliberately reference DDL, not a versioned migration history. The +-- local SQLite adapter applies it only to bootstrap a new development database. +-- Production deployments must use reviewed, forward-only migrations. CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, @@ -59,8 +58,8 @@ CREATE TABLE IF NOT EXISTS webhook_receipts ( metadata TEXT NOT NULL DEFAULT '{}' ); -CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id, id); -CREATE INDEX IF NOT EXISTS idx_memberships_user ON memberships(user_id, project_id); +CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id, created_at, id); +CREATE INDEX IF NOT EXISTS idx_memberships_user ON memberships(user_id, created_at, project_id); CREATE INDEX IF NOT EXISTS idx_tasks_project_cursor ON tasks(project_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_tasks_project_status ON tasks(project_id, status, created_at, id); CREATE INDEX IF NOT EXISTS idx_webhook_receipts_status ON webhook_receipts(status, received_at); diff --git a/src/taskbox/adapters/sqlite.py b/src/taskbox/adapters/sqlite.py index 5a6bee7..49655f8 100644 --- a/src/taskbox/adapters/sqlite.py +++ b/src/taskbox/adapters/sqlite.py @@ -12,6 +12,7 @@ import threading from collections.abc import Callable from datetime import UTC, datetime +from importlib.resources import files from pathlib import Path from typing import Any, TypeVar @@ -31,18 +32,7 @@ _T = TypeVar("_T") -SCHEMA = """ -PRAGMA foreign_keys = ON; -CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL); -CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, name TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); -CREATE TABLE IF NOT EXISTS memberships (project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (project_id, user_id)); -CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'todo', priority INTEGER NOT NULL DEFAULT 0, due_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); -CREATE TABLE IF NOT EXISTS webhook_receipts (id TEXT PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, signature TEXT NOT NULL, payload_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'received', received_at TEXT NOT NULL, processed_at TEXT, error TEXT, metadata TEXT NOT NULL DEFAULT '{}'); -CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id, created_at, id); -CREATE INDEX IF NOT EXISTS idx_memberships_user ON memberships(user_id, created_at, project_id); -CREATE INDEX IF NOT EXISTS idx_tasks_project_cursor ON tasks(project_id, created_at, id); -CREATE INDEX IF NOT EXISTS idx_webhook_receipts_status ON webhook_receipts(status, received_at); -""" +REFERENCE_SCHEMA = files("taskbox.adapters").joinpath("reference_schema.sql").read_text() def utcnow() -> datetime: @@ -102,7 +92,7 @@ def __init__(self, url: str = "sqlite:///taskbox.db") -> None: self.connection = sqlite3.connect(target, **kwargs) self.connection.row_factory = sqlite3.Row self.connection.execute("PRAGMA foreign_keys = ON") - self.connection.executescript(SCHEMA) + self.connection.executescript(REFERENCE_SCHEMA) self.connection.commit() self.lock = threading.RLock() self.cursor_codec = CursorCodec() diff --git a/src/taskbox/api/dependencies.py b/src/taskbox/api/dependencies.py index f928dde..e943593 100644 --- a/src/taskbox/api/dependencies.py +++ b/src/taskbox/api/dependencies.py @@ -5,7 +5,7 @@ from taskbox.domain.models import User -bearer = HTTPBearer(auto_error=False) +bearer = HTTPBearer(auto_error=False, scheme_name="bearerAuth", bearerFormat="JWT") def services(request: Request): @@ -13,7 +13,7 @@ def services(request: Request): def current_user( - credentials: HTTPAuthorizationCredentials | None = Depends(bearer), request: Request = None + request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer) ) -> User: if not credentials: raise HTTPException( @@ -34,11 +34,11 @@ def current_user( def optional_user( - credentials: HTTPAuthorizationCredentials | None = Depends(bearer), request: Request = None + request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer) ) -> User | None: if not credentials: return None - return current_user(credentials, request) + return current_user(request=request, credentials=credentials) __all__ = ["current_user", "optional_user", "services"] diff --git a/src/taskbox/api/routes.py b/src/taskbox/api/routes.py index ac38d2f..27c8dc3 100644 --- a/src/taskbox/api/routes.py +++ b/src/taskbox/api/routes.py @@ -6,35 +6,76 @@ from taskbox.api.schemas import ( LoginRequest, MembershipCreate, + MembershipPage, MembershipResponse, MembershipUpdate, ProjectCreate, + ProjectPage, ProjectResponse, ProjectUpdate, RegisterRequest, TaskCreate, + TaskPage, TaskResponse, TaskUpdate, TokenResponse, UserResponse, + WebhookImportResponse, ) from taskbox.domain.models import TaskStatus, User router = APIRouter(prefix="/api/v1") +_PROBLEM_DESCRIPTIONS = { + 400: "Cursor is invalid or expired", + 401: "Authentication is required", + 403: "The authenticated user lacks the required project role", + 404: "Resource not found", + 409: "Resource conflicts with existing state", + 422: "Request validation failed", +} + + +def problems(*status_codes: int) -> dict[int, dict]: + """Declare the RFC 9457 responses served by the application handlers.""" + return { + code: { + "description": _PROBLEM_DESCRIPTIONS[code], + "content": { + "application/problem+json": { + "schema": {"$ref": "#/components/schemas/ProblemDetail"} + } + }, + } + for code in status_codes + } + def page(items, cursor): return {"items": items, "next_cursor": cursor} -@router.post("/auth/register", response_model=UserResponse, status_code=201, tags=["Auth"]) +@router.post( + "/auth/register", + response_model=UserResponse, + status_code=201, + tags=["Auth"], + operation_id="registerUser", + responses=problems(409, 422), +) def register(body: RegisterRequest, svc=Depends(services)): # noqa: F405 return svc.auth.register( email=str(body.email), password=body.password, display_name=body.display_name ) -@router.post("/auth/token", response_model=TokenResponse, tags=["Auth"]) +@router.post( + "/auth/token", + response_model=TokenResponse, + tags=["Auth"], + operation_id="createToken", + responses=problems(401, 422), +) def token(body: LoginRequest, svc=Depends(services)): # noqa: F405 return { "access_token": svc.auth.authenticate(email=str(body.email), password=body.password), @@ -43,12 +84,24 @@ def token(body: LoginRequest, svc=Depends(services)): # noqa: F405 } -@router.get("/me", response_model=UserResponse, tags=["Auth"]) +@router.get( + "/me", + response_model=UserResponse, + tags=["Auth"], + operation_id="getCurrentUser", + responses=problems(401), +) def me(user: User = Depends(current_user)): return user -@router.get("/projects", tags=["Projects"]) +@router.get( + "/projects", + response_model=ProjectPage, + tags=["Projects"], + operation_id="listProjects", + responses=problems(401), +) def list_projects( cursor: str | None = None, limit: int = Query(50, ge=1, le=100), @@ -59,17 +112,36 @@ def list_projects( return page(list(result.items), result.next_cursor) -@router.post("/projects", response_model=ProjectResponse, status_code=201, tags=["Projects"]) +@router.post( + "/projects", + response_model=ProjectResponse, + status_code=201, + tags=["Projects"], + operation_id="createProject", + responses=problems(401, 422), +) def create_project(body: ProjectCreate, user: User = Depends(current_user), svc=Depends(services)): # noqa: F405 return svc.projects.create(actor_id=user.id, name=body.name, description=body.description) -@router.get("/projects/{project_id}", response_model=ProjectResponse, tags=["Projects"]) +@router.get( + "/projects/{project_id}", + response_model=ProjectResponse, + tags=["Projects"], + operation_id="getProject", + responses=problems(401, 404), +) def get_project(project_id: str, user: User = Depends(current_user), svc=Depends(services)): # noqa: F405 return svc.projects.get(actor_id=user.id, project_id=project_id) -@router.patch("/projects/{project_id}", response_model=ProjectResponse, tags=["Projects"]) +@router.patch( + "/projects/{project_id}", + response_model=ProjectResponse, + tags=["Projects"], + operation_id="updateProject", + responses=problems(401, 403, 404, 422), +) def update_project( project_id: str, body: ProjectUpdate, user: User = Depends(current_user), svc=Depends(services) ): # noqa: F405 @@ -82,13 +154,25 @@ def update_project( ) -@router.delete("/projects/{project_id}", status_code=204, tags=["Projects"]) +@router.delete( + "/projects/{project_id}", + status_code=204, + tags=["Projects"], + operation_id="deleteProject", + responses=problems(401, 403, 404), +) def delete_project(project_id: str, user: User = Depends(current_user), svc=Depends(services)): svc.projects.delete(actor_id=user.id, project_id=project_id) return Response(status_code=204) -@router.get("/projects/{project_id}/members", tags=["Projects"]) +@router.get( + "/projects/{project_id}/members", + response_model=MembershipPage, + tags=["Projects"], + operation_id="listMembers", + responses=problems(401, 403, 404), +) def list_members( project_id: str, cursor: str | None = None, @@ -107,6 +191,8 @@ def list_members( response_model=MembershipResponse, status_code=201, tags=["Projects"], + operation_id="addMember", + responses=problems(401, 403, 404, 409), ) def add_member( project_id: str, @@ -120,7 +206,11 @@ def add_member( @router.patch( - "/projects/{project_id}/members/{user_id}", response_model=MembershipResponse, tags=["Projects"] + "/projects/{project_id}/members/{user_id}", + response_model=MembershipResponse, + tags=["Projects"], + operation_id="updateMemberRole", + responses=problems(401, 403, 404), ) def update_member( project_id: str, @@ -134,7 +224,13 @@ def update_member( ) -@router.delete("/projects/{project_id}/members/{user_id}", status_code=204, tags=["Projects"]) +@router.delete( + "/projects/{project_id}/members/{user_id}", + status_code=204, + tags=["Projects"], + operation_id="removeMember", + responses=problems(401, 403, 404), +) def remove_member( project_id: str, user_id: str, user: User = Depends(current_user), svc=Depends(services) ): @@ -142,7 +238,13 @@ def remove_member( return Response(status_code=204) -@router.get("/projects/{project_id}/tasks", tags=["Tasks"]) +@router.get( + "/projects/{project_id}/tasks", + response_model=TaskPage, + tags=["Tasks"], + operation_id="listTasks", + responses=problems(400, 401, 403, 404), +) def list_tasks( project_id: str, cursor: str | None = None, @@ -164,7 +266,12 @@ def list_tasks( @router.post( - "/projects/{project_id}/tasks", response_model=TaskResponse, status_code=201, tags=["Tasks"] + "/projects/{project_id}/tasks", + response_model=TaskResponse, + status_code=201, + tags=["Tasks"], + operation_id="createTask", + responses=problems(401, 403, 404, 422), ) def create_task( project_id: str, body: TaskCreate, user: User = Depends(current_user), svc=Depends(services) @@ -181,12 +288,24 @@ def create_task( ) -@router.get("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"]) +@router.get( + "/tasks/{task_id}", + response_model=TaskResponse, + tags=["Tasks"], + operation_id="getTask", + responses=problems(401, 403, 404), +) def get_task(task_id: str, user: User = Depends(current_user), svc=Depends(services)): # noqa: F405 return svc.tasks.get(actor_id=user.id, task_id=task_id) -@router.patch("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"]) +@router.patch( + "/tasks/{task_id}", + response_model=TaskResponse, + tags=["Tasks"], + operation_id="updateTask", + responses=problems(401, 403, 404, 422), +) def update_task( task_id: str, body: TaskUpdate, user: User = Depends(current_user), svc=Depends(services) ): # noqa: F405 @@ -195,13 +314,37 @@ def update_task( ) -@router.delete("/tasks/{task_id}", status_code=204, tags=["Tasks"]) +@router.delete( + "/tasks/{task_id}", + status_code=204, + tags=["Tasks"], + operation_id="deleteTask", + responses=problems(401, 403, 404), +) def delete_task(task_id: str, user: User = Depends(current_user), svc=Depends(services)): svc.tasks.delete(actor_id=user.id, task_id=task_id) return Response(status_code=204) -@router.post("/webhooks/tasks/import", status_code=202, tags=["Webhooks"]) +@router.post( + "/webhooks/tasks/import", + response_model=WebhookImportResponse, + status_code=202, + tags=["Webhooks"], + operation_id="importTasksWebhook", + responses=problems(400, 401, 409), + openapi_extra={ + "security": [], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/WebhookImportRequest"} + } + }, + }, + }, +) async def import_tasks( request: Request, x_webhook_event_id: str = Header(...), @@ -216,7 +359,7 @@ async def import_tasks( event_id=x_webhook_event_id, actor_id=user.id if user else None, ) - return {"event_id": x_webhook_event_id, "imported": len(imported), "duplicate": False} + return {"event_id": x_webhook_event_id, "imported": len(imported)} __all__ = ["router"] diff --git a/src/taskbox/api/schemas.py b/src/taskbox/api/schemas.py index 1f9633d..ba3d9e9 100644 --- a/src/taskbox/api/schemas.py +++ b/src/taskbox/api/schemas.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -34,11 +35,11 @@ def valid_email(cls, value: str) -> str: class TokenResponse(BaseModel): access_token: str - token_type: str = "bearer" + token_type: Literal["bearer"] expires_in: int -class UserResponse(BaseModel): +class User(BaseModel): model_config = ConfigDict(from_attributes=True) id: str email: str @@ -58,7 +59,7 @@ class ProjectUpdate(BaseModel): description: str | None = Field(default=None, max_length=2000) -class ProjectResponse(BaseModel): +class Project(BaseModel): model_config = ConfigDict(from_attributes=True) id: str owner_id: str @@ -77,7 +78,7 @@ class MembershipUpdate(BaseModel): role: ProjectRole -class MembershipResponse(BaseModel): +class Membership(BaseModel): model_config = ConfigDict(from_attributes=True) project_id: str user_id: str @@ -104,7 +105,7 @@ class TaskUpdate(BaseModel): due_at: datetime | None = None -class TaskResponse(BaseModel): +class Task(BaseModel): model_config = ConfigDict(from_attributes=True) id: str project_id: str @@ -121,11 +122,60 @@ class TaskResponse(BaseModel): class PageResponse(BaseModel): items: list - next_cursor: str | None = None + next_cursor: str | None -class HealthResponse(BaseModel): - status: str = "ok" +class ProjectPage(BaseModel): + items: list[Project] + next_cursor: str | None + + +class MembershipPage(BaseModel): + items: list[Membership] + next_cursor: str | None + + +class TaskPage(BaseModel): + items: list[Task] + next_cursor: str | None + + +class PageInfo(BaseModel): + next_cursor: str | None + + +class Health(BaseModel): + status: Literal["ok"] + + +class WebhookImportResponse(BaseModel): + event_id: str + imported: int = Field(ge=0) + + +class WebhookImportRequest(BaseModel): + project_id: str + actor_id: str | None = None + tasks: list[TaskCreate] = Field(min_length=1) + + +class ProblemDetail(BaseModel): + type: str + title: str + status: int + detail: str + instance: str | None = None + code: str | None = None + errors: list[dict] | None = None + + +# Route imports retain their descriptive response aliases while the OpenAPI +# component names match the curated public contract. +UserResponse = User +ProjectResponse = Project +MembershipResponse = Membership +TaskResponse = Task +HealthResponse = Health __all__ = [ @@ -135,13 +185,20 @@ class HealthResponse(BaseModel): "MembershipResponse", "MembershipUpdate", "PageResponse", + "PageInfo", + "ProjectPage", "ProjectCreate", "ProjectResponse", "ProjectUpdate", "RegisterRequest", "TaskCreate", "TaskResponse", + "TaskPage", "TaskUpdate", "TokenResponse", "UserResponse", + "MembershipPage", + "WebhookImportRequest", + "WebhookImportResponse", + "ProblemDetail", ] diff --git a/src/taskbox/application/services.py b/src/taskbox/application/services.py index 83bda42..48f37cd 100644 --- a/src/taskbox/application/services.py +++ b/src/taskbox/application/services.py @@ -40,6 +40,10 @@ def __init__( self, uow_factory: Callable[[], UnitOfWork], hasher: PasswordHasher, tokens: TokenIssuer ) -> None: self.uow_factory, self.hasher, self.tokens = uow_factory, hasher, tokens + # Use a real hash for failed lookups so a missing account does not skip + # the expensive password-verification path. This narrows, but does not + # eliminate, timing differences; public endpoints still need rate limits. + self._missing_user_password_hash = self.hasher.hash("taskbox-missing-user-password") def register(self, *, email: str, password: str, display_name: str) -> User: if len(password) < 8: @@ -56,10 +60,12 @@ def register(self, *, email: str, password: str, display_name: str) -> User: def authenticate(self, *, email: str, password: str) -> str: with self.uow_factory() as uow: user = uow.users.get_by_email(email) + password_hash = user.password_hash if user else self._missing_user_password_hash + password_is_valid = self.hasher.verify(password, password_hash) if ( not user or user.status is not UserStatus.ACTIVE - or not self.hasher.verify(password, user.password_hash) + or not password_is_valid ): raise AuthenticationError("invalid email or password") return self.tokens.issue(subject=user.id) diff --git a/src/taskbox/main.py b/src/taskbox/main.py index fd91c07..ead2e5a 100644 --- a/src/taskbox/main.py +++ b/src/taskbox/main.py @@ -3,10 +3,12 @@ from __future__ import annotations import os +from types import SimpleNamespace from typing import Any from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError +from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse from taskbox.adapters.security import ( @@ -16,6 +18,7 @@ ) from taskbox.adapters.sqlite import SQLiteDatabase from taskbox.api.routes import router +from taskbox.api.schemas import HealthResponse, PageInfo, ProblemDetail, WebhookImportRequest from taskbox.application.services import ( AuthApplicationService, ProjectApplicationService, @@ -24,6 +27,37 @@ ) from taskbox.domain.errors import TaskBoxError +_DEFAULT_JWT_SECRET = "dev-only-change-me" +_DEFAULT_WEBHOOK_SECRET = "dev-webhook-secret" +_INSECURE_DEVELOPMENT_SECRETS = frozenset( + {_DEFAULT_JWT_SECRET, _DEFAULT_WEBHOOK_SECRET, "change-me-in-development"} +) +_LOCAL_ENVIRONMENTS = frozenset({"development", "local", "test", "testing"}) + + +def _configured_secret( + explicit_value: str | None, environment_variable: str, default: str +) -> str: + """Resolve a factory override before environment configuration.""" + if explicit_value is not None: + return explicit_value + return os.getenv(environment_variable, default) + + +def _validate_runtime_secrets(*, environment: str, jwt_secret: str, webhook_secret: str) -> None: + """Reject unsafe secret configuration before opening the local database.""" + for setting, value in ( + ("TASKBOX_JWT_SECRET", jwt_secret), + ("TASKBOX_WEBHOOK_SECRET", webhook_secret), + ): + if not value.strip(): + raise RuntimeError(f"{setting} must not be empty") + if environment not in _LOCAL_ENVIRONMENTS and value in _INSECURE_DEVELOPMENT_SECRETS: + raise RuntimeError( + f"{setting} uses an insecure development value while TASKBOX_ENV={environment!r}; " + "configure a long random secret before starting TaskBox" + ) + def _problem( request: Request, @@ -33,7 +67,7 @@ def _problem( errors: list[dict[str, Any]] | None = None, ) -> JSONResponse: body: dict[str, Any] = { - "type": f"https://taskbox.dev/problems/{code}", + "type": f"{str(request.base_url).rstrip('/')}/problems/{code}", "title": code.replace("_", " ").title(), "status": status_code, "detail": detail, @@ -56,14 +90,33 @@ def create_app( jwt_secret: str | None = None, webhook_secret: str | None = None, ) -> FastAPI: - app = FastAPI(title="TaskBox API", version="1.0.0") + environment = os.getenv("TASKBOX_ENV", "development").strip().lower() + configured_jwt_secret = _configured_secret( + jwt_secret, "TASKBOX_JWT_SECRET", _DEFAULT_JWT_SECRET + ) + configured_webhook_secret = _configured_secret( + webhook_secret, "TASKBOX_WEBHOOK_SECRET", _DEFAULT_WEBHOOK_SECRET + ) + _validate_runtime_secrets( + environment=environment, + jwt_secret=configured_jwt_secret, + webhook_secret=configured_webhook_secret, + ) + + app = FastAPI( + title="TaskBox API", + version="1.0.0", + description="A project task API used throughout the API Engineering course.", + servers=[{"url": "http://localhost:8000", "description": "Local development"}], + openapi_tags=[ + {"name": tag} for tag in ("Health", "Auth", "Projects", "Tasks", "Webhooks") + ], + ) db = SQLiteDatabase(database_url or os.getenv("TASKBOX_DATABASE_URL", "sqlite:///taskbox.db")) tokens = JWTTokenIssuer( - jwt_secret or os.getenv("TASKBOX_JWT_SECRET", "dev-only-change-me"), + configured_jwt_secret, expires_seconds=int(os.getenv("TASKBOX_JWT_EXPIRES", "3600")), ) - from types import SimpleNamespace - factory = db.transaction app.state.database = db app.state.services = SimpleNamespace( @@ -73,13 +126,36 @@ def create_app( tasks=TaskApplicationService(factory), webhooks=WebhookImportApplicationService( factory, - HMACWebhookSignatureVerifier( - webhook_secret or os.getenv("TASKBOX_WEBHOOK_SECRET", "dev-webhook-secret") - ), + HMACWebhookSignatureVerifier(configured_webhook_secret), ), ) app.include_router(router) + def custom_openapi() -> dict[str, Any]: + if app.openapi_schema: + return app.openapi_schema + document = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + tags=app.openapi_tags, + servers=app.servers, + ) + schemas = document.setdefault("components", {}).setdefault("schemas", {}) + schemas["ProblemDetail"] = ProblemDetail.model_json_schema() + schemas["WebhookImportRequest"] = WebhookImportRequest.model_json_schema( + ref_template="#/components/schemas/{model}" + ) + schemas["PageInfo"] = PageInfo.model_json_schema() + # A Bearer token is accepted to select an actor but is not required for + # this signed webhook boundary, so it has no OpenAPI security requirement. + document["paths"]["/api/v1/webhooks/tasks/import"]["post"].pop("security", None) + app.openapi_schema = document + return document + + app.openapi = custom_openapi # type: ignore[method-assign] + @app.exception_handler(TaskBoxError) async def domain_error(request: Request, exc: TaskBoxError): return _problem(request, exc.status_code, exc.detail, exc.code) @@ -96,14 +172,14 @@ async def validation_error(request: Request, exc: RequestValidationError): ] return _problem(request, 422, "request validation failed", "validation_error", errors) - @app.get("/healthz", tags=["Auth"]) - @app.get("/livez", tags=["Auth"], include_in_schema=False) + @app.get("/healthz", response_model=HealthResponse, tags=["Health"], operation_id="healthCheck") + @app.get("/livez", tags=["Health"], include_in_schema=False) async def healthz(): return {"status": "ok"} - @app.get("/readyz", tags=["Auth"], include_in_schema=False) + @app.get("/readyz", tags=["Health"], include_in_schema=False) async def readyz(): - db.connection.execute("SELECT 1") + db.run(lambda connection: connection.execute("SELECT 1")) return {"status": "ok"} return app diff --git a/tests/contract/test_openapi_contract.py b/tests/contract/test_openapi_contract.py index ec4f7b1..e52de1b 100644 --- a/tests/contract/test_openapi_contract.py +++ b/tests/contract/test_openapi_contract.py @@ -1,4 +1,4 @@ -"""Executable checks that keep the generated API aligned with the course contract.""" +"""Contract-level verification for the generated TaskBox OpenAPI document.""" from __future__ import annotations @@ -8,6 +8,8 @@ import pytest +from scripts.check_openapi_contract import validate + def _app(): for module_name in ("taskbox.api", "taskbox.main", "taskbox.app"): @@ -15,17 +17,12 @@ def _app(): module = importlib.import_module(module_name) except ImportError: continue - if hasattr(module, "app") and hasattr(module.app, "openapi"): - return module.app + app = getattr(module, "app", None) + if app is not None and hasattr(app, "openapi"): + return app pytest.skip("TaskBox HTTP application is not present in this lab checkout") -def test_generated_paths_match_committed_contract() -> None: - expected = json.loads(Path("contracts/taskbox.openapi.json").read_text()) - actual = _app().openapi() - methods = {"get", "post", "put", "patch", "delete", "options", "head"} - expected_routes = { - (p, m) for p, item in expected["paths"].items() for m in item if m in methods - } - actual_routes = {(p, m) for p, item in actual["paths"].items() for m in item if m in methods} - assert actual_routes == expected_routes +def test_generated_document_matches_curated_contract() -> None: + contract = json.loads(Path("contracts/taskbox.openapi.json").read_text(encoding="utf-8")) + assert validate(contract, _app().openapi()) == [] diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py index 0089d95..d091702 100644 --- a/tests/integration/test_health.py +++ b/tests/integration/test_health.py @@ -6,6 +6,8 @@ import pytest +from taskbox.main import create_app + def test_healthz() -> None: for module_name in ("taskbox.api", "taskbox.main", "taskbox.app"): @@ -21,3 +23,75 @@ def test_healthz() -> None: assert response.status_code == 200 return pytest.skip("TaskBox HTTP application is not present in this lab checkout") + + +def test_readyz_uses_locked_database_runner() -> None: + from fastapi.testclient import TestClient + + app = create_app(database_url="sqlite:///:memory:") + database = app.state.database + original_run = database.run + calls = 0 + + def locked_run(callback): + nonlocal calls + calls += 1 + return original_run(callback) + + database.run = locked_run + response = TestClient(app).get("/readyz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + assert calls == 1 + + +def test_healthz_is_grouped_under_the_health_openapi_tag() -> None: + app = create_app(database_url="sqlite:///:memory:") + + document = app.openapi() + + assert {"name": "Health"} in document["tags"] + assert document["paths"]["/healthz"]["get"]["tags"] == ["Health"] + + +@pytest.mark.parametrize("environment", ["production", "staging"]) +@pytest.mark.parametrize( + ("setting", "development_value"), + [ + ("TASKBOX_JWT_SECRET", "dev-only-change-me"), + ("TASKBOX_WEBHOOK_SECRET", "dev-webhook-secret"), + ("TASKBOX_JWT_SECRET", "change-me-in-development"), + ], +) +def test_strict_environments_reject_known_development_secrets( + monkeypatch, environment: str, setting: str, development_value: str +) -> None: + monkeypatch.setenv("TASKBOX_ENV", environment) + monkeypatch.setenv("TASKBOX_JWT_SECRET", "jwt-secret-for-a-production-test") + monkeypatch.setenv("TASKBOX_WEBHOOK_SECRET", "webhook-secret-for-a-production-test") + monkeypatch.setenv(setting, development_value) + + with pytest.raises(RuntimeError, match=setting): + create_app(database_url="sqlite:///:memory:") + + +def test_local_development_retains_disposable_secret_defaults(monkeypatch) -> None: + monkeypatch.setenv("TASKBOX_ENV", "development") + monkeypatch.delenv("TASKBOX_JWT_SECRET", raising=False) + monkeypatch.delenv("TASKBOX_WEBHOOK_SECRET", raising=False) + + app = create_app(database_url="sqlite:///:memory:") + + assert app.title == "TaskBox API" + + +def test_empty_secrets_are_rejected_in_every_environment(monkeypatch) -> None: + monkeypatch.setenv("TASKBOX_ENV", "development") + + with pytest.raises(RuntimeError, match="TASKBOX_JWT_SECRET"): + create_app( + database_url="sqlite:///:memory:", + jwt_secret="", + webhook_secret="webhook-secret-for-a-test", + ) diff --git a/tests/integration/test_reference_schema.py b/tests/integration/test_reference_schema.py new file mode 100644 index 0000000..61c0167 --- /dev/null +++ b/tests/integration/test_reference_schema.py @@ -0,0 +1,59 @@ +"""Database-level checks for the reference SQLite bootstrap DDL.""" + +from __future__ import annotations + +import pytest + +from taskbox.adapters.sqlite import SQLiteDatabase +from taskbox.domain.errors import PersistenceError + + +def test_reference_schema_enforces_checks_and_installs_query_indexes() -> None: + database = SQLiteDatabase("sqlite:///:memory:") + try: + with pytest.raises(PersistenceError): + database.run( + lambda connection: connection.execute( + "INSERT INTO users VALUES " + "('bad', 'bad@example.com', 'hash', 'Bad', 'unknown', 'now', 'now')" + ) + ) + + database.run( + lambda connection: connection.execute( + "INSERT INTO users VALUES " + "('user', 'user@example.com', 'hash', 'User', 'active', 'now', 'now')" + ) + ) + database.run( + lambda connection: connection.execute( + "INSERT INTO projects VALUES ('project', 'user', 'Project', NULL, 'now', 'now')" + ) + ) + for statement in ( + "INSERT INTO memberships VALUES ('project', 'user', 'invalid', 'now', 'now')", + "INSERT INTO tasks VALUES " + "('task', 'project', 'user', NULL, 'Task', NULL, 'invalid', 0, NULL, 'now', 'now')", + "INSERT INTO tasks VALUES " + "('task', 'project', 'user', NULL, 'Task', NULL, 'todo', 5, NULL, 'now', 'now')", + "INSERT INTO webhook_receipts VALUES " + "('receipt', 'event', 'sig', 'hash', 'invalid', 'now', NULL, NULL, '{}')", + ): + with pytest.raises(PersistenceError): + database.run(lambda connection, sql=statement: connection.execute(sql)) + + indexes = database.run( + lambda connection: { + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'index'") + } + ) + assert { + "idx_projects_owner", + "idx_memberships_user", + "idx_tasks_project_cursor", + "idx_tasks_project_status", + "idx_webhook_receipts_status", + } <= indexes + finally: + database.close() diff --git a/tests/integration/test_taskbox_workflow.py b/tests/integration/test_taskbox_workflow.py index 30554c1..5707d8e 100644 --- a/tests/integration/test_taskbox_workflow.py +++ b/tests/integration/test_taskbox_workflow.py @@ -34,6 +34,55 @@ def _bearer(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} +def test_problem_type_uses_the_request_host() -> None: + app = create_app(database_url="sqlite:///:memory:") + + with TestClient(app, base_url="http://127.0.0.1:8000") as client: + first = client.post( + "/api/v1/auth/register", + json={ + "email": "duplicate@example.com", + "password": "correct-horse-battery-staple", + "display_name": "Duplicate", + }, + ) + duplicate = client.post( + "/api/v1/auth/register", + json={ + "email": "duplicate@example.com", + "password": "correct-horse-battery-staple", + "display_name": "Duplicate", + }, + ) + + assert first.status_code == 201 + assert duplicate.status_code == 409 + assert duplicate.json()["type"] == "http://127.0.0.1:8000/problems/conflict" + + +def test_unknown_login_still_runs_password_verification(monkeypatch) -> None: + app = create_app(database_url="sqlite:///:memory:") + hasher = app.state.services.auth.hasher + verification_calls: list[tuple[str, str]] = [] + + def verify(password: str, password_hash: str) -> bool: + verification_calls.append((password, password_hash)) + return False + + monkeypatch.setattr(hasher, "verify", verify) + with TestClient(app) as client: + response = client.post( + "/api/v1/auth/token", + json={"email": "missing@example.com", "password": "incorrect-password"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "invalid email or password" + assert verification_calls == [ + ("incorrect-password", app.state.services.auth._missing_user_password_hash) + ] + + def test_authenticated_project_task_and_webhook_workflow() -> None: webhook_secret = "integration-webhook-secret" app = create_app( @@ -108,11 +157,25 @@ def test_authenticated_project_task_and_webhook_workflow() -> None: "X-Webhook-Signature": f"sha256={signature}", } + missing_event_id = client.post( + "/api/v1/webhooks/tasks/import", + content=payload, + headers={"X-Webhook-Signature": f"sha256={signature}"}, + ) + assert missing_event_id.status_code == 422 + + altered_body = client.post( + "/api/v1/webhooks/tasks/import", + content=payload + b" ", + headers=webhook_headers, + ) + assert altered_body.status_code == 401 + imported = client.post( "/api/v1/webhooks/tasks/import", content=payload, headers=webhook_headers ) assert imported.status_code == 202 - assert imported.json()["imported"] == 1 + assert imported.json() == {"event_id": "evt-integration-1", "imported": 1} duplicate = client.post( "/api/v1/webhooks/tasks/import", content=payload, headers=webhook_headers diff --git a/tests/legacy/test_fixed_flask_api.py b/tests/legacy/test_fixed_flask_api.py index 5048559..8e41253 100644 --- a/tests/legacy/test_fixed_flask_api.py +++ b/tests/legacy/test_fixed_flask_api.py @@ -190,6 +190,17 @@ def test_malformed_json_is_bad_request(client: Any) -> None: _assert_problem(response, 400) +def test_put_malformed_json_is_bad_request(client: Any) -> None: + response = client.put( + "/api/v1/jokes/0", + data="{not-json", + content_type="application/json", + headers={"Authorization": _auth_header()}, + ) + + assert _assert_problem(response, 400)["code"] == "malformed_json" + + def test_writes_require_basic_auth_and_advertise_challenge(client: Any) -> None: for method, path in ( ("post", "/api/v1/jokes"), diff --git a/tests/packaging/test_wheel_package.py b/tests/packaging/test_wheel_package.py new file mode 100644 index 0000000..3ee8d3f --- /dev/null +++ b/tests/packaging/test_wheel_package.py @@ -0,0 +1,65 @@ +"""Smoke-test the wheel rather than relying on an editable source checkout.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from zipfile import ZipFile + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def test_built_wheel_includes_and_loads_reference_schema(tmp_path: Path) -> None: + """The SQL resource must survive packaging because app import reads it eagerly.""" + wheel_dir = tmp_path / "wheel" + subprocess.run( + [ + sys.executable, + "-m", + "hatchling", + "build", + "--target", + "wheel", + "--directory", + str(wheel_dir), + ], + check=True, + cwd=PROJECT_ROOT, + ) + + wheel = next(wheel_dir.glob("taskbox_api_course-*.whl")) + extracted_wheel = tmp_path / "installed-wheel" + with ZipFile(wheel) as archive: + assert "taskbox/adapters/reference_schema.sql" in archive.namelist() + archive.extractall(extracted_wheel) + + environment = os.environ | { + "PYTHONPATH": str(extracted_wheel), + "TASKBOX_ENV": "test", + "TASKBOX_DATABASE_URL": "sqlite:///:memory:", + "TASKBOX_JWT_SECRET": "wheel-test-jwt-secret", + "TASKBOX_WEBHOOK_SECRET": "wheel-test-webhook-secret", + } + smoke_test = """ +import sys +from importlib.resources import files +from pathlib import Path + +import taskbox.main +from taskbox.adapters import sqlite + +wheel_root = Path(sys.argv[1]).resolve() +assert Path(taskbox.main.__file__).resolve().is_relative_to(wheel_root) +assert Path(sqlite.__file__).resolve().is_relative_to(wheel_root) +schema = files("taskbox.adapters").joinpath("reference_schema.sql") +assert "CREATE TABLE IF NOT EXISTS users" in schema.read_text() +assert "CREATE TABLE IF NOT EXISTS users" in sqlite.REFERENCE_SCHEMA +""" + subprocess.run( + [sys.executable, "-c", smoke_test, str(extracted_wheel)], + check=True, + cwd=tmp_path, + env=environment, + ) diff --git a/uv.lock b/uv.lock index c4ceca8..5609a43 100644 --- a/uv.lock +++ b/uv.lock @@ -6,20 +6,6 @@ resolution-markers = [ "python_full_version < '3.14'", ] -[[package]] -name = "alembic" -version = "1.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.5" @@ -254,6 +240,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hatchling" +version = "1.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomlkit" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/08/33331757185504aae48b8d9bd78cec03a76e3aecfb52e549d05a2347c0dd/hatchling-1.32.0.tar.gz", hash = "sha256:0bdbde4a52b06c37e3eca395f85a762bf0ef06fe374fd8ae429dc6be10230f5f", size = 57783, upload-time = "2026-08-11T05:03:44.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/84/1798b6d85ecde0e31546004efd25c5de1b1f49250644a60cce460e12593a/hatchling-1.32.0-py3-none-any.whl", hash = "sha256:0e17c9c3b9aa7c625acc8d0f5b622f107d5049af9ecf5ada4de1aada5be7cdbc", size = 78435, upload-time = "2026-08-11T05:03:42.644Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -405,18 +407,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, ] -[[package]] -name = "mako" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -810,44 +800,75 @@ name = "taskbox-api-course" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "alembic" }, { name = "fastapi" }, - { name = "flask" }, { name = "httpx" }, { name = "pwdlib", extra = ["argon2"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, - { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] +[package.optional-dependencies] +legacy = [ + { name = "flask" }, +] +persistence-labs = [ + { name = "sqlalchemy" }, +] + [package.dev-dependencies] dev = [ + { name = "flask" }, + { name = "hatchling" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pyyaml" }, { name = "ruff" }, + { name = "sqlalchemy" }, ] [package.metadata] requires-dist = [ - { name = "alembic", specifier = ">=1.15,<2" }, { name = "fastapi", specifier = ">=0.115,<1" }, - { name = "flask", specifier = ">=3.1,<4" }, + { name = "flask", marker = "extra == 'legacy'", specifier = ">=3.1,<4" }, { name = "httpx", specifier = ">=0.28,<1" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.2,<1" }, { name = "pydantic-settings", specifier = ">=2.7,<3" }, { name = "pyjwt", specifier = ">=2.10,<3" }, - { name = "sqlalchemy", specifier = ">=2.0,<3" }, + { name = "sqlalchemy", marker = "extra == 'persistence-labs'", specifier = ">=2.0,<3" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" }, ] +provides-extras = ["legacy", "persistence-labs"] [package.metadata.requires-dev] dev = [ + { name = "flask", specifier = ">=3.1,<4" }, + { name = "hatchling", specifier = ">=1.32,<2" }, { name = "mypy", specifier = ">=1.14,<2" }, { name = "pytest", specifier = ">=8.3,<9" }, { name = "pytest-asyncio", specifier = ">=0.25,<1" }, + { name = "pyyaml", specifier = ">=6,<7" }, { name = "ruff", specifier = ">=0.9,<1" }, + { name = "sqlalchemy", specifier = ">=2.0,<3" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2026.6.1.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" }, ] [[package]]