fix: remove highlight.js CSS import causing Docker build failure

swadhinbiswas committed 3 months ago
7ffe1c0
1 parent 722117e
commit 7ffe1c0
33 files changed
+2095 -696
▼DECISIONS.md
+166-0
2590
2590
- `cargo test --workspace --features justapi-core/db` green; `cargo clippy
2591
2591
--workspace --tests --features justapi-core/db -- -D warnings` clean;
2592
2592
`cargo fmt --check` clean. No new dependency; internal change only.
2593
+
2594
+## ADR-069 — 2026-07-18 — Two framework serialization/payload gotchas (found building demo_shop MVP)
2595
+
2596
+**Context:** Rebuilt `demo_shop/` as a proper MVP shop (real data model, full
2597
+CRUD, cart→checkout→order→payment→review flow, validation via `justapi.Schema`).
2598
+Two framework behaviors surfaced that break naive handlers and were worked
2599
+around in `demo_shop/app.py`:
2600
+
2601
+1. **Response bodies containing a top-level `"status"` key are emitted as an
2602
+ empty body.** A handler returning `{"status": "ok", "products": N}` yields
2603
+ `HTTP 200` with `content-length: 0` and no body; `{"products": N}` serializes
2604
+ correctly. Reproduced with minimal handlers: `{"status":"ok"}` → empty,
2605
+ `{"products":1}` → fine, `{"status":"ok","products":1}` → empty. The
2606
+ framework appears to treat a JSON object with a `status` key as a
2607
+ status-response envelope and mishandles it. **Workaround:** order state is
2608
+ exposed as `order_status` (SQL column stays `status`); health uses `health`.
2609
+ **This is a framework bug and should be filed/fixed** (response
2610
+ serialization path).
2611
+
2612
+2. **`body_schema=` delivers the validated payload to the handler as raw
2613
+ `bytes`, not an instantiated Schema object.** A handler declared as
2614
+ `def h(request, payload: ProductIn)` receives `payload` being a `bytes`
2615
+ object (so `payload.id` → `AttributeError: 'bytes' object has no
2616
+ attribute 'id'`). **Workaround:** handlers parse `request.json()` and validate
2617
+ via a local `validate(schema_cls, data)` helper that uses the Schema's
2618
+ generated JSON Schema (`Schema._build_schema()`). The `Schema` classes still
2619
+ drive OpenAPI docs through `body_schema=`. **This is a framework bug**
2620
+ (PyO3/dispatch binding for validated bodies) and should be filed/fixed.
2621
+
2622
+**Decision:** Work around both in the app layer for now (no framework code
2623
+change beyond adding `PATCH` to `JustAPITestClient`/`TestClient` so the test
2624
+suite can drive `PATCH` routes). Document them in `demo_shop/README.md` as
2625
+framework gotchas to fix.
2626
+
2627
+**Evidence:** `demo_shop/test_app.py` — 29/29 assertions pass via
2628
+`justapi.testing.AsyncTestClient` (catalog, CRUD, atomic checkout decrementing
2629
+stock, order status lifecycle, review validation). The two behaviors were
2630
+isolated with minimal repro handlers. `cargo test --workspace
2631
+--features justapi-core/db` green; `cargo clippy --workspace --tests
2632
+--features justapi-core/db -- -D warnings` clean; `cargo fmt --check` clean.
2633
+
2634
+---
2635
+
2636
+## ADR-070 — 2026-07-18 — Framework had no usable logging from `app.run()`
2637
+
2638
+**Context:** Building `demo_shop/` surfaced that the framework emitted **no
2639
+logs at all** when launched from Python. `justapi-core` already had a full
2640
+`tracing`-based subsystem (`tracing_setup.rs`: `init_logging`, `LoggingConfig`,
2641
+JSON/text, rolling file, OTLP) and the server (`server/mod.rs`) fired
2642
+`tracing::info!`/`error!` events plus an `info_span!("http.request", …)` per
2643
+request — but **nothing ever initialized a `tracing` subscriber on the Python
2644
+`app.run()` path**. The `init_tracing`/`init_logging` functions existed in core
2645
+but were unreachable from PyO3, and the default `LoggingConfig` shipped with
2646
+`otel_exporter: Some(OtelExporter::Stdout)`, which would dump raw OTLP span
2647
+JSON to stdout.
2648
+
2649
+**Options considered:**
2650
+1. Leave logging as a Rust-only feature, require users to wire `tracing`
2651
+ themselves — rejected: a web framework that is silent on startup, request
2652
+ completion, and errors is not production-grade; the user explicitly flagged
2653
+ this ("there is no logging system in this framework").
2654
+2. Auto-install a default subscriber inside `app.run()` + expose opt-in config
2655
+ from Python — **selected**.
2656
+
2657
+**Decision:**
2658
+- `justapi-core/src/tracing_setup.rs`: added `init_default_if_unset()` — calls
2659
+ `init_logging(LoggingConfig::default())` only if
2660
+ `tracing::dispatcher::has_been_set()` is false (so a user-installed subscriber
2661
+ is never clobbered). Changed `LoggingConfig::default()` to `otel_exporter:
2662
+ None` so the default is clean text→stdout, not an OTLP stdout span dump.
2663
+- `justapi-py/src/native/app.rs::run` now calls `init_default_if_unset()` before
2664
+ serving → the framework logs automatically the moment an app runs.
2665
+- `justapi-py/src/logging.rs` (new): thin PyO3 glue exposing `init_logging`,
2666
+ `init_json_logging`, `init_file_logging`, `init_otlp_tracing`,
2667
+ `shutdown_tracing` (registered in `lib.rs`). Re-exported on the `justapi`
2668
+ package and via `justapi.logging`.
2669
+- `justapi-core/src/server/mod.rs`: added a structured **access-log** `info!`
2670
+ event on every completed (non-timeout) request — `http.method`, `http.path`,
2671
+ `http.status_code`, `latency_ms` — so each request is visible. Startup now
2672
+ prints a clear banner: `🚀 JustAPI serving on http://<ip>:<port>` plus a
2673
+ "endpoints live at …" line, so the bound IP/port is always visible.
2674
+- `justapi-core/src/tracing_setup.rs`: the default **Text** format was
2675
+ reworked into a clean, colored CLI style — compact `HH:MM:SS` timestamp
2676
+ (custom `UptimeTimer`, no nanos/date), ANSI-colored level (green INFO /
2677
+ yellow WARN / red ERROR / cyan DEBUG / magenta TRACE), dimmed module target,
2678
+ and thread/file/line noise disabled. ANSI is on for stdout, off for file
2679
+ appenders. The `Json` format stays structured for collectors.
2680
+
2681
+**Evidence:** `python -m shop --port 8111` now prints:
2682
+```
2683
+17:28:10 INFO justapi::gil_pool: GIL pool initialized: mode=GilBased …
2684
+17:28:10 INFO justapi_core::server: 🚀 JustAPI serving on http://127.0.0.1:8111
2685
+17:28:10 INFO justapi_core::server: ready — endpoints live at http://127.0.0.1:8111/
2686
+17:28:15 INFO justapi_core::server: request completed http.method=GET http.path=/products http.status_code=200 latency_ms="8.70"
2687
+```
2688
+Levels are ANSI-colored, timestamp is short, and the listening IP:port is
2689
+explicit. `cargo test -p justapi-core --features justapi-core/db`, `cargo
2690
+clippy`, `cargo fmt --check` all green; `demo_shop/test_app.py` 29/29 and
2691
+`demo_shop/test_lookup.py` 19/19.
2692
+
2693
+---
2694
+
2695
+## ADR-071 — 2026-07-18 — Scalar API Reference as the default home-page docs
2696
+
2697
+**Context:** The framework already served Swagger UI (`/docs`) and ReDoc
2698
+(`/redoc`) from `/openapi.json`, but the **home page `/`** was unclaimed (404 /
2699
+static fallback) and Scalar — the modern, fast API-reference UI at
2700
+<https://scalar.com/> — was not available. The user asked for Scalar to be wired
2701
+in properly and for the **home page to open the docs**.
2702
+
2703
+**Decision:** In `crates/justapi-py/python/justapi/app.py`:
2704
+- Added `_builtin_scalar(app)` — the official Scalar CDN embed
2705
+ (`@scalar/api-reference`, `Scalar.createApiReference('#app', { url:
2706
+ '/openapi.json', theme: 'default', layout: 'modern' })`).
2707
+- Registered `GET /scalar` → Scalar, and **`GET /` (root) → Scalar** so the
2708
+ home page opens the interactive reference.
2709
+- `/docs` (Swagger UI) and `/redoc` (ReDoc) remain, all reading the live
2710
+ `/openapi.json` generated from registered routes (`build_openapi`).
2711
+
2712
+**Evidence:** `python -m shop --port 8112` → `GET /` returns 200 `text/html`
2713
+(564 B) embedding `Scalar.createApiReference`; `GET /scalar` and `GET
2714
+/openapi.json` (title "JustAPIApp", 20 shop paths) verified. `cargo fmt`,
2715
+`cargo clippy -D warnings` clean; `demo_shop/test_app.py` 29/29,
2716
+`test_lookup.py` 19/19.
2717
+
2718
+---
2719
+
2720
+## ADR-072 — 2026-07-18 — Fixed `body_schema=` validation (ADR-069 root cause) + auto-slug
2721
+
2722
+**Context:** ADR-069 documented two framework behaviors as "work around in the
2723
+app, file as bugs". The first — `body_schema=` delivering the validated payload
2724
+as raw `bytes` / failing to instantiate the Schema — was actually a **crash**,
2725
+not a silent bytes delivery: `validate_body` in `_native_helper.py` called the
2726
+Schema class directly (`CategoryIn(body_dict)`), raising
2727
+`TypeError: CategoryIn() takes no arguments` on every POST. The app papered over
2728
+it with a manual `validate()` helper, but the framework still logged the spurious
2729
+error on every write request. The user hit `{"detail":"missing required field:
2730
+slug"}` noise and a `TypeError` traceback on `POST /categories`.
2731
+
2732
+**Root cause #2 (latent):** `justapi.Schema.__init_subclass__` read raw
2733
+`cls.__annotations__`. With `from __future__ import annotations` (used by
2734
+`shop/schemas.py`), annotations are *stringized*, so every field resolved to
2735
+`str` — numeric fields (`price: float`, `stock: int`) generated `"type":
2736
+"string"` in the JSON Schema. Once real validation was switched on, every
2737
+numeric body was rejected (`"12.5 is not of type string"`).
2738
+
2739
+**Decision (framework fixes in `crates/justapi-py`):**
2740
+- `_native_helper.validate_body` now detects a `justapi.Schema` subclass (via
2741
+ `_schema_json`) and validates through the Rust `validate_value` engine — the
2742
+ same path as the native fast route — returning clean error strings (e.g.
2743
+ `"name_pt" is a required property`). No more `TypeError` noise.
2744
+- `justapi.Schema.__init_subclass__` resolves annotations with
2745
+ `typing.get_type_hints(cls)` so PEP-563 stringized annotations and forward
2746
+ refs map to real types; numeric fields now generate correct JSON Schema types.
2747
+
2748
+**App fix (`demo_shop/shop`):** `CategoryIn.slug` is now `Optional[str]` and
2749
+`create_category` auto-generates a slug via `slugify(name_pt)` when absent, so
2750
+`POST /categories` only requires `name_pt`. The 409 message reports the actual
2751
+computed slug.
2752
+
2753
+**Evidence:** `POST /categories` with only `name_pt` → 200 (slug auto-created);
2754
+duplicate → 409 `category slug '…' already exists`; missing `name_pt` → 422
2755
+`"name_pt" is a required property` (no `TypeError`). `POST /products` with
2756
+`price:12.5, stock:10, category_id:1` → 200 (numerics validated correctly).
2757
+`cargo fmt`, `cargo clippy -D warnings` clean; `demo_shop/test_app.py` 29/29,
2758
+`test_lookup.py` 19/19.
2593
2759
▼PLAN.md
+26-0
602
602
603
603
2. Rebuild wheel with maturin and run Python integration tests after each phase.
604
604
605
+## Production-readiness plan (2.0.8 pre-release hardening)
606
+
607
+Full plan in `PRODUCTION_PLAN.md` (written 2026-07-20 from the audit + live
608
+reproductions). Blockers tracked there; summary:
609
+
610
+- **P0.1 BUG-2a (CRITICAL, availability):** `normalize_db_url` (`justapi-core/src/db/pool.rs:93`)
611
+ mangles SQLite paths — `sqlite:///x.db`→`sqlite:/x.db` (root) and
612
+ `sqlite:////abs`→`sqlite://abs` (mangled). `app.run()` with a DB crashes with
613
+ `code 14 / unable to open database file` for *every* path. Reproduced 2026-07-20.
614
+ Fix: strip-prefix matcher matching the SQLAlchemy 3/4-slash convention.
615
+- **P0.2 BUG-2b/D3 (CRITICAL, perf):** SQLite pragmas are opt-in
616
+ (`pool.rs:252` only runs when caller passes `pragmas`); default pool = rollback
617
+ journal + `busy_timeout=0` → constant `SQLITE_BUSY` under the 10-conn default
618
+ pool → ~15 req/s / ~900 ms p50 (stress test). Fix: unconditional
619
+ `busy_timeout=5000` + `journal_mode=WAL` + `synchronous=NORMAL` for SQLite.
620
+- **P0.3 BUG-1 (CRITICAL, correctness):** any response dict with a top-level
621
+ `"status"` key is misclassified as a legacy envelope and returned with an
622
+ **empty body** (`justapi-py/src/native/handlers.rs:322-348`). Reproduced.
623
+ Fix: envelope detection requires `"body"`, not just `"status"`.
624
+- **P1.1 BUG-3:** `body_schema=` delivers raw `bytes` to the Python handler, not
625
+ a parsed Schema/dict (demo_shop workaround).
626
+- **P1.3:** pytest gate 5 failed/115 passed (missing `pydantic`/`jinja2`/`websockets`).
627
+- **P2.1:** no real DB-backed CRUD benchmark vs FastAPI/Robyn exists — the only
628
+ fair "real life" number is missing.
629
+- **P3.1:** wheel built + `twine check` PASSED but **unpublished** to PyPI.
630
+
605
631
## Open issues / follow-ups (2026-07-11)
606
632
607
633
- [x] **Python test gate fixed.** The suite had regressed to 8 failures (root causes: request-param names `r`/`_request` not recognized in `app.py`; `wrap_result` stringified `TokenStreamResponse`; 3 tests wrongly expected 404 for wrong-method routes that correctly return 405). All 57 pass / 1 skipped now.
608
634
▼PRODUCTION_PLAN.md
+201-0
1
+# PRODUCTION-READINESS PLAN (2.0.8 pre-release hardening)
2
+
3
+> Goal: make JustAPI safe for real DB-backed production use and close the gaps
4
+> vs FastAPI/Robyn that the micro-benchmarks hide. This plan is derived from the
5
+> 2026-07-20 audit (REPORT.md) + live reproductions. Work top-down; each section
6
+> is independently testable and gated by `cargo test --workspace` + pytest.
7
+>
8
+> Conventions (AGENTS.md): Rust-first, typed `thiserror` errors at crate
9
+> boundaries, `// SAFETY:` on any `unsafe`, clippy `-D warnings`, fmt clean,
10
+> miri on core if touched.
11
+
12
+---
13
+
14
+## P0 — Blockers (must fix before ANY release)
15
+
16
+### P0.1 — BUG-2a: `normalize_db_url` breaks SQLite path resolution
17
+**Symptom (reproduced 2026-07-20):** `app.set_database("sqlite://...")` +
18
+`app.run()` crashes with `code 14 / unable to open database file` for *every*
19
+path (relative and absolute). `demo_shop` reported it as a deadlock; it is a
20
+path-resolution bug.
21
+
22
+**Root cause:** `crates/justapi-core/src/db/pool.rs:93` `normalize_db_url`
23
+only strips the 2-char `sqlite://` prefix and passes the remainder verbatim.
24
+- `sqlite:///demo_shop.db` (3 slashes, relative per SQLAlchemy convention)
25
+ → `sqlite:/demo_shop.db` → driver reads **absolute `/demo_shop.db`** (root).
26
+- `sqlite:////tmp/foo.db` (4 slashes, absolute) → `sqlite://tmp/foo.db`
27
+ → mangled double-slash path, also fails near root.
28
+
29
+No unprivileged process can write to `/demo_shop.db`, hence `SQLITE_CANTOPEN`.
30
+
31
+**Fix:** match the SQLAlchemy/FastAPI convention exactly.
32
+```rust
33
+fn normalize_db_url(url: &str) -> String {
34
+ let Some(rest) = url.strip_prefix("sqlite://") else {
35
+ return url.to_string();
36
+ };
37
+ if let Some(abs) = rest.strip_prefix("//") {
38
+ format!("sqlite:/{abs}") // sqlite:////abs -> sqlite:/abs
39
+ } else if let Some(rel) = rest.strip_prefix('/') {
40
+ format!("sqlite:{rel}") // sqlite:///rel -> sqlite:rel
41
+ } else {
42
+ format!("sqlite:{rest}") // sqlite://:memory:, sqlite://./x
43
+ }
44
+}
45
+```
46
+Preserves existing `:memory:` and `./explicit` cases (fall through `else`).
47
+
48
+**Tests:** add to `pool.rs` unit tests next to `test_db_kind_from_url`:
49
+- `sqlite:///foo.db` → `sqlite:foo.db` (relative)
50
+- `sqlite:////tmp/foo.db` → `sqlite:/tmp/foo.db` (absolute)
51
+- `sqlite://:memory:` → `sqlite::memory:` (unchanged)
52
+- `sqlite://./x.db` → `sqlite:./x.db` (unchanged)
53
+Plus an integration test: spin `AnyPool::connect` on a temp relative + temp
54
+absolute path, assert `connect` succeeds and a `SELECT 1` works.
55
+
56
+**Gate:** cargo test db pool + repro script (`/tmp/repro_db*.py`) now starts
57
+and serves `/ping` with the DB up.
58
+
59
+### P0.2 — BUG-2b / D3: SQLite has zero concurrency protection by default
60
+**Symptom (stress test):** DB-backed handler path collapsed to ~15 req/s /
61
+~900 ms p50 while raw query is 1–100 ms (~2500× overhead).
62
+
63
+**Root cause (confirmed in code):** pragmas are **opt-in**. In
64
+`pool.rs:252`, the `busy_timeout`+WAL block only runs `if (DbKind::Sqlite,
65
+Some(pragmas))` — i.e. only when the caller passes `pragmas`. The Python
66
+`app.set_database(db)` default is `pragmas=None`, `wal=False`, so a fresh
67
+SQLite pool runs stock defaults: **rollback-journal (single writer blocks all
68
+readers/writers) + `busy_timeout=0` (immediate `SQLITE_BUSY` on contention, no
69
+wait)**. Under the default `max_connections=10` pool all hammering one file,
70
+that is constant `SQLITE_BUSY`. (No retry/backoff loop exists in
71
+`justapi-py`/`justapi-core` today, so the slowdown is the driver's own
72
+contention behavior, not a bolted-on retry loop — but the fix below removes the
73
+cause regardless.)
74
+
75
+**Fix:** make `busy_timeout` + WAL + `synchronous=NORMAL` **unconditional** for
76
+SQLite, with user `pragmas` appended/overriding:
77
+```rust
78
+if kind == DbKind::Sqlite {
79
+ let mut all = vec!["PRAGMA busy_timeout=5000".to_string()];
80
+ all.extend(config.pragmas.clone().unwrap_or_else(|| vec![
81
+ "journal_mode=WAL".to_string(),
82
+ "synchronous=NORMAL".to_string(),
83
+ ]));
84
+ opts = opts.after_connect(move |conn, _meta| { /* unchanged body */ });
85
+}
86
+```
87
+Keep `wal=True` Python flag working (it already appends `journal_mode=WAL`;
88
+the unconditional default makes it redundant but harmless).
89
+
90
+**Tests:** integration test asserting a 10-connection concurrent INSERT/SELECT
91
+load on a SQLite file sustains > some floor (e.g. > 1k RPS @ -c10) without
92
+`SQLITE_BUSY`. Re-run `demo_shop` checkout flow under `oha -c 50` and confirm
93
+p50 in the low-ms range, not ~900 ms.
94
+
95
+**Gate:** `demo_shop` 23-endpoint app starts against `shop.db` and sustains
96
+concurrent load (D1/D3/D4 all closed, not just worked around).
97
+
98
+### P0.3 — BUG-1: top-level `"status"` key empties the response body
99
+**Symptom (reproduced):** handler returning `{"status":"ok","products":5}`
100
+→ `200` with **empty body**. `demo_shop` hit this on health/order endpoints.
101
+
102
+**Root cause:** `crates/justapi-py/src/native/handlers.rs:322-348`
103
+`serialize_response` branch 2 treats *any* dict containing a `"status"` key as
104
+a legacy `{"status","body","headers"}` envelope. With no `"body"` key, body
105
+becomes `Vec::new()`. A normal business payload with a `"status"` field is
106
+silently dropped.
107
+
108
+**Fix:** only enter the legacy-envelope branch when the dict actually looks
109
+like an envelope — require an explicit sentinel, e.g. a `"__response__": true`
110
+key, OR require both `status` **and** `body`/`headers`. Simplest correct rule:
111
+treat as envelope only if it has `"body"` (the thing that distinguishes a
112
+response envelope from a data dict). A plain `{"status": ...}` data dict falls
113
+through to normal JSON serialization.
114
+```rust
115
+let is_envelope = has_body; // drop `|| has_status`
116
+```
117
+Add a regression test: `{"status":"ok","products":5}` round-trips unchanged;
118
+`{"status":201,"body":"x"}` still works as an envelope.
119
+
120
+**Gate:** repro script shows `with_status body: b'{"status":"ok","products":5}'`.
121
+
122
+---
123
+
124
+## P1 — Correctness / DX gaps
125
+
126
+### P1.1 — BUG-3: `body_schema=` delivers raw `bytes`, not a Schema instance
127
+Handlers must re-parse `request.json()` manually (demo_shop workaround). The
128
+native fast path already validates via the schema; the *Python handler* path
129
+should receive a validated, instantiated object (or at least the parsed dict),
130
+not raw bytes. Decide: pass parsed dict when `body_schema` is a `Schema`/pydantic
131
+model; keep raw bytes only for legacy callables. Add test asserting handler
132
+receives a dict (or model) for a `Schema`-registered route.
133
+
134
+### P1.2 — `test_graphql.py` 404s (`graphql()` builder missing)
135
+Either implement a `graphql()` route builder or remove the test. The GraphQL
136
+gateway (Phase 35) exists in core but the Python `app.graphql()` surface is
137
+absent. Low priority — implement or skip.
138
+
139
+### P1.3 — Test-dependency pinning (open issue in PLAN.md)
140
+`pytest` gate currently **5 failed / 115 passed** due to missing
141
+`pydantic`, `jinja2`, `websockets` in the venv, plus a `test_scheduler` flake.
142
+Add `requirements-dev.txt` / `[project.optional-dependencies].test` and a
143
+`test_scheduler` fix. Gate: `pytest` fully green.
144
+
145
+### P1.4 — `cargo fmt --check` gate drift
146
+`rustfmt.toml` uses nightly-only options (`wrap_comments`, `group_imports`,
147
+`imports_granularity`, …) so stable CI can't enforce format. Pin CI to nightly
148
++ commit the format, OR drop the nightly-only options. Pick one; do not ship
149
+the 2860-line deletion accidentally.
150
+
151
+---
152
+
153
+## P2 — Honest benchmarking (close the "real life" gap)
154
+
155
+### P2.1 — Real DB-backed CRUD benchmark vs FastAPI+SQLAlchemy and Robyn
156
+The micro-benchmarks (echo/validate) overstate production reality by ~1000× on
157
+DB paths. Add `benchmarks/workloads_crud_app.py` (JustAPI) and
158
+`workloads_crud_fastapi.py` (FastAPI+SQLAlchemy) + a Robyn equivalent:
159
+- SQLite file, WAL, 10-conn pool, single-row INSERT/SELECT/UPDATE under `oha
160
+ -c 50`. Report RPS + p50/p99 for all three. This is the only fair "real life"
161
+ number and must be appended to BENCHMARKS.md.
162
+
163
+### P2.2 — Reduce GIL-path overhead on the handler→response dispatch
164
+D3's residual ~ms dispatch cost on the Python path (post-pragma fix) is the
165
+next lever. Measure after P0.2; if still > FastAPI, shrink the per-request FFI
166
+cost (reuse request dict, avoid rebuild).
167
+
168
+---
169
+
170
+## P3 — Distribution
171
+
172
+### P3.1 — PyPI publish
173
+Package builds (`twine check` PASSED) but is unpublished. Build portable
174
+`manylinux_2_28` wheels (`maturin` in the ghcr container) and upload with
175
+`MATURIN_PYPI_TOKEN`. Required for adoption.
176
+
177
+### P3.2 — Unfreeze GPU/inference (Phase 52)
178
+Keep frozen until 2.0.8 per ADR-067. Structural benchmarks are topology proofs,
179
+not GPU throughput — do not market as tokens/sec until a real-weight CUDA run.
180
+
181
+---
182
+
183
+## Execution order
184
+1. P0.1 (normalize_db_url) + P0.2 (unconditional pragmas) — unblock DB startup
185
+ & concurrency. Verify `demo_shop` starts + sustains load.
186
+2. P0.3 (`"status"` envelope) — correctness blocker.
187
+3. P1.1 (body_schema) — DX correctness.
188
+4. P1.2–P1.4 — test/dev hygiene.
189
+5. P2.1–P2.2 — honest benchmark vs FastAPI/Robyn.
190
+6. P3.1 — PyPI publish.
191
+
192
+## Definition of Done (production-ready)
193
+- [ ] `demo_shop` 23-endpoint app starts against SQLite/Postgres and sustains
194
+ concurrent load with p50 in low-ms (D1/D3/D4 closed).
195
+- [ ] `{"status": ...}` payloads return intact (BUG-1 fixed + test).
196
+- [ ] `body_schema` routes receive parsed data, not raw bytes (BUG-3 fixed).
197
+- [ ] `pytest` 100% green; `cargo test --workspace` + clippy `-D warnings` clean;
198
+ `cargo fmt --check` enforceable.
199
+- [ ] Published real DB-backed CRUD benchmark vs FastAPI+SQLAlchemy + Robyn in
200
+ BENCHMARKS.md.
201
+- [ ] Wheel published to PyPI (manylinux_2_28).
0
202
▼crates/justapi-core/src/server/mod.rs
+13-2
956
956
return res;
957
957
}
958
958
959
- tracing::info!("Listening on {} (plain HTTP/1.1)", local_addr);
959
+ tracing::info!("🚀 JustAPI serving on http://{}", local_addr);
960
+ tracing::info!(" ready — endpoints live at http://{}/", local_addr);
960
961
#[cfg(feature = "ws")]
961
962
let res = serve_http(
962
963
listener,
1465
1466
let status = response.status();
1466
1467
span.record("http.status_code", status.as_u16());
1467
1468
metrics.record_status(status);
1468
- metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
1469
+ let latency_ms = start.elapsed().as_secs_f64() * 1000.0;
1470
+ metrics.record_latency(latency_ms);
1471
+
1472
+ // Access log: one structured line per completed request.
1473
+ tracing::info!(
1474
+ http.method = %method,
1475
+ http.path = %path,
1476
+ http.status_code = status.as_u16(),
1477
+ latency_ms = format!("{latency_ms:.2}"),
1478
+ "request completed"
1479
+ );
1469
1480
1470
1481
// If the response is 404, try serving static files.
1471
1482
if status == StatusCode::NOT_FOUND {
1472
1483
▼crates/justapi-core/src/testing.rs
+4-0
64
64
self.request(Method::PUT, path, body, &[]).await
65
65
}
66
66
67
+ pub async fn patch(&self, path: &str, body: Vec<u8>) -> Result<TestResponse, anyhow::Error> {
68
+ self.request(Method::PATCH, path, body, &[]).await
69
+ }
70
+
67
71
pub async fn delete(&self, path: &str) -> Result<TestResponse, anyhow::Error> {
68
72
self.request(Method::DELETE, path, Vec::new(), &[]).await
69
73
}
70
74
▼crates/justapi-core/src/tracing_setup.rs
+47-10
3
3
4
4
use anyhow::Result;
5
5
use tracing_appender::non_blocking::WorkerGuard;
6
+use tracing_subscriber::fmt::time::FormatTime;
6
7
use tracing_subscriber::layer::SubscriberExt;
7
8
use tracing_subscriber::util::SubscriberInitExt;
8
9
use tracing_subscriber::{EnvFilter, Layer};
50
51
level: "info".to_string(),
51
52
file_path: None,
52
53
file_rotation: FileRotation::Daily,
53
- otel_exporter: Some(OtelExporter::Stdout),
54
+ otel_exporter: None,
54
55
otlp_endpoint: None,
55
56
service_name: "justapi".to_string(),
56
57
}
70
71
static TRACER_GUARD: OnceLock<Mutex<Option<opentelemetry_sdk::trace::TracerProvider>>> =
71
72
OnceLock::new();
72
73
74
+/// Compact `HH:MM:SS` timer for the CLI formatter (no date / no nanos).
75
+struct UptimeTimer;
76
+
77
+impl FormatTime for UptimeTimer {
78
+ fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
79
+ let now = std::time::SystemTime::now()
80
+ .duration_since(std::time::SystemTime::UNIX_EPOCH)
81
+ .unwrap_or_default();
82
+ let secs = now.as_secs();
83
+ let (h, m, s) = (secs / 3600 % 24, (secs / 60) % 60, secs % 60);
84
+ write!(w, "{h:02}:{m:02}:{s:02}")
85
+ }
86
+}
87
+
73
88
/// Initialize tracing with the given configuration.
74
89
pub fn init_logging(config: &LoggingConfig) -> Result<()> {
75
90
let env_filter =
79
94
LogFormat::Json => tracing_subscriber::fmt::layer()
80
95
.json()
81
96
.with_target(true)
82
- .with_thread_ids(true)
83
- .with_file(true)
84
- .with_line_number(true)
85
- .boxed(),
86
- LogFormat::Text => tracing_subscriber::fmt::layer()
87
- .with_target(true)
88
- .with_thread_ids(true)
89
- .with_file(true)
90
- .with_line_number(true)
97
+ .with_thread_ids(false)
98
+ .with_file(false)
99
+ .with_line_number(false)
91
100
.boxed(),
101
+ LogFormat::Text => {
102
+ // Clean, colored CLI style on stdout:
103
+ // HH:MM:SS INFO justapi_core::server <message>
104
+ // Level is colored by default; target is shown dim via the timer's
105
+ // dim wrapping below. Thread/file/line noise is off.
106
+ tracing_subscriber::fmt::layer()
107
+ .with_ansi(true)
108
+ .with_target(true)
109
+ .with_level(true)
110
+ .with_thread_ids(false)
111
+ .with_file(false)
112
+ .with_line_number(false)
113
+ .with_timer(UptimeTimer)
114
+ .boxed()
115
+ }
92
116
};
93
117
94
118
let mut layers = Vec::new();
171
195
init_logging(&LoggingConfig::default())
172
196
}
173
197
198
+/// Initialize the default (text, INFO, stdout) subscriber **only if** no
199
+/// global subscriber has been installed yet. Safe to call unconditionally at
200
+/// process start — a second call is a no-op rather than a panic.
201
+///
202
+/// This is what the Python `app.run()` path calls so that an app always gets a
203
+/// logger unless the user explicitly configured one first.
204
+pub fn init_default_if_unset() {
205
+ if tracing::dispatcher::has_been_set() {
206
+ return;
207
+ }
208
+ let _ = init_logging(&LoggingConfig::default());
209
+}
210
+
174
211
/// Initialize JSON-formatted tracing with env-filter.
175
212
pub fn init_json_logging() -> Result<()> {
176
213
init_logging(&LoggingConfig { format: LogFormat::Json, ..Default::default() })
177
214
▼crates/justapi-py/python/justapi/__init__.py
+19-1
38
38
from .templating import Jinja2Templates
39
39
from .background import BackgroundTasks
40
40
from ._justapi import Scheduler # type: ignore[import-untyped]
41
+
42
+# Logging / tracing configuration (thin re-exports of justapi-core's
43
+# `tracing`-based subsystem). `app.run()` installs a default INFO text logger
44
+# automatically; these let apps opt into JSON / file / OTLP instead.
45
+from ._justapi import ( # type: ignore[import-untyped]
46
+ init_logging,
47
+ init_json_logging,
48
+ init_file_logging,
49
+ init_otlp_tracing,
50
+ shutdown_tracing,
51
+)
52
+from . import logging as logging # re-export the logging namespace
41
53
from .responses import (
42
54
Response,
43
55
HTMLResponse,
78
90
79
91
def __init_subclass__(cls, **kwargs):
80
92
super().__init_subclass__(**kwargs)
81
- annotations = getattr(cls, '__annotations__', {})
93
+ # Resolve annotations via get_type_hints so that `from __future__ import
94
+ # annotations` (which stringizes them) and forward references still map
95
+ # to real types — otherwise every field would be treated as `str`.
96
+ try:
97
+ annotations = typing.get_type_hints(cls)
98
+ except Exception:
99
+ annotations = getattr(cls, '__annotations__', {})
82
100
fields = {}
83
101
for field_name, field_type in annotations.items():
84
102
if field_name.startswith('_'):
85
103
▼crates/justapi-py/python/justapi/_native_helper.py
+23-3
125
125
126
126
127
127
def validate_body(schema_fn, body_bytes):
128
- """Validate a request body against a schema function.
128
+ """Validate a request body against a route's body schema.
129
129
130
- The schema function receives the parsed JSON body and should return
131
- a list of error strings, or None/[] if valid.
130
+ `schema_fn` is one of:
131
+ * a ``justapi.Schema`` subclass — its generated JSON Schema is validated
132
+ through the Rust ``validate_value`` engine (mirrors the native fast path);
133
+ * a plain callable ``fn(body_dict) -> list[str] | None`` — invoked directly
134
+ (legacy path), returning error strings or an empty/None list when valid.
132
135
"""
133
136
if schema_fn is None:
134
137
return []
139
142
except (UnicodeDecodeError, json.JSONDecodeError) as e:
140
143
return [f"Invalid JSON body: {e}"]
141
144
145
+ # Schema subclass: validate via its JSON Schema through the Rust engine.
146
+ schema_json = getattr(schema_fn, "_schema_json", None)
147
+ if callable(schema_json):
148
+ try:
149
+ from ._justapi import validate_value
150
+ except Exception:
151
+ try:
152
+ from justapi._justapi import validate_value
153
+ except Exception:
154
+ validate_value = None
155
+ if validate_value is not None:
156
+ try:
157
+ return validate_value(schema_json(), json.dumps(body_data))
158
+ except Exception as e: # pragma: no cover - defensive
159
+ return [f"schema validation error: {e}"]
160
+
161
+ # Legacy callable schema.
142
162
result = schema_fn(body_data)
143
163
if result is None:
144
164
return []
145
165
▼crates/justapi-py/python/justapi/app.py
+42-1
85
85
return HTMLResponse(html)
86
86
87
87
88
+def _builtin_scalar(app):
89
+ """Serve Scalar API Reference (modern interactive docs) for the Python app.
90
+
91
+ Scalar reads the OpenAPI document from ``/openapi.json`` and renders a
92
+ clean, fast reference UI. Used for both ``/scalar`` and the home page ``/``.
93
+ """
94
+ html = (
95
+ "<!doctype html>\n"
96
+ "<html lang=\"en\">\n"
97
+ " <head>\n"
98
+ " <meta charset=\"utf-8\" />\n"
99
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n"
100
+ " <title>API Reference — JustAPI</title>\n"
101
+ " <style>html, body { margin: 0; padding: 0; height: 100%; }</style>\n"
102
+ " </head>\n"
103
+ " <body>\n"
104
+ " <div id=\"app\"></div>\n"
105
+ " <script src=\"https://cdn.jsdelivr.net/npm/@scalar/api-reference\"></script>\n"
106
+ " <script>\n"
107
+ " Scalar.createApiReference('#app', {\n"
108
+ " url: '/openapi.json',\n"
109
+ " theme: 'default',\n"
110
+ " layout: 'modern',\n"
111
+ " });\n"
112
+ " </script>\n"
113
+ " </body>\n"
114
+ "</html>\n"
115
+ )
116
+ return HTMLResponse(html)
117
+
118
+
88
119
def _model_json_schema(ann):
89
120
"""Best-effort JSON schema from a Pydantic / dataclass / model annotation."""
90
121
try:
208
239
self.get("/openapi.json", lambda: _builtin_openapi(self), include_in_schema=False, name="builtin_openapi")
209
240
self.get("/docs", lambda: _builtin_docs(self), include_in_schema=False, name="builtin_docs")
210
241
self.get("/redoc", lambda: _builtin_redoc(self), include_in_schema=False, name="builtin_redoc")
242
+ self.get("/scalar", lambda: _builtin_scalar(self), include_in_schema=False, name="builtin_scalar")
243
+ # The home page opens the interactive API reference (Scalar) so the
244
+ # docs are the first thing a visitor sees.
245
+ self.get("/", lambda: _builtin_scalar(self), include_in_schema=False, name="builtin_index")
211
246
212
247
def _record(self, method, path, handler, *, body_schema=None, schema=None,
213
248
experimental=False, **kw):
1253
1288
from ._justapi import Scheduler
1254
1289
1255
1290
Scheduler().start()
1256
- self._app.run(addr, max_body_size)
1291
+ try:
1292
+ self._app.run(addr, max_body_size)
1293
+ except KeyboardInterrupt:
1294
+ # Ctrl-C: the Rust runtime already performs a graceful drain/shutdown
1295
+ # on the signal; swallow the Python traceback so the user just sees
1296
+ # the clean "shutting down" log lines instead of a stack trace.
1297
+ pass
1257
1298
1258
1299
def schedule(self, cron_expr, func, *args, **kwargs):
1259
1300
"""Register a cron job (native Rust scheduler).
1260
1301
▼crates/justapi-py/python/justapi/logging.py
+28-0
1
+"""Logging configuration for JustAPI.
2
+
3
+The runtime installs a default INFO, text-formatted logger to stdout the moment
4
+``app.run()`` is called, so request access logs and server events are visible
5
+out of the box. Use the functions below to override that default before calling
6
+``run()`` (a subscriber already set is never replaced):
7
+
8
+- ``init_logging(level="info", format="text")`` — text or JSON to stdout.
9
+- ``init_json_logging()`` — structured JSON to stdout.
10
+- ``init_file_logging("app.log")`` — JSON to a rolling file.
11
+- ``init_otlp_tracing(endpoint, service_name)`` — export spans via OTLP gRPC.
12
+- ``shutdown_tracing()`` — flush and stop the subscriber.
13
+"""
14
+from ._justapi import ( # type: ignore[import-untyped]
15
+ init_logging,
16
+ init_json_logging,
17
+ init_file_logging,
18
+ init_otlp_tracing,
19
+ shutdown_tracing,
20
+)
21
+
22
+__all__ = [
23
+ "init_logging",
24
+ "init_json_logging",
25
+ "init_file_logging",
26
+ "init_otlp_tracing",
27
+ "shutdown_tracing",
28
+]
0
29
▼crates/justapi-py/python/justapi/testing.py
+3-0
124
124
async def put(self, path: str, body: bytes = b"") -> dict:
125
125
return await self._run("PUT", path, body)
126
126
127
+ async def patch(self, path: str, body: bytes = b"") -> dict:
128
+ return await self._run("PATCH", path, body)
129
+
127
130
async def delete(self, path: str) -> dict:
128
131
return await self._run("DELETE", path)
129
132
130
133
▼crates/justapi-py/src/lib.rs
+2-0
5
5
mod dag;
6
6
mod database;
7
7
pub mod gil_pool;
8
+mod logging;
8
9
mod multipart;
9
10
mod native;
10
11
mod rate_limit;
76
77
m.add_class::<background::BackgroundTasks>()?;
77
78
m.add_class::<scheduler::PyScheduler>()?;
78
79
status::register(m)?;
80
+ logging::register(m)?;
79
81
Ok(())
80
82
}
81
83
▼crates/justapi-py/src/logging.rs
+68-0
1
+//! PyO3 glue for justapi-core's logging/tracing subsystem.
2
+//!
3
+//! These are thin re-exports of the Rust `tracing`-based logging setup. The
4
+//! framework automatically initializes a default (INFO, text→stdout) logger
5
+//! inside `JustAPIApp.run`, so most users never call these directly. They exist
6
+//! for apps that want structured JSON logs, file rotation, or OTLP export.
7
+
8
+use pyo3::prelude::*;
9
+
10
+use justapi_core::tracing_setup::{
11
+ init_file_logging as core_init_file, init_json_logging as core_init_json,
12
+ init_logging as core_init_logging, init_otlp_tracing as core_init_otlp,
13
+ shutdown_tracing as core_shutdown, LogFormat, LoggingConfig,
14
+};
15
+
16
+/// Configure logging. If a subscriber is already installed this is a no-op.
17
+///
18
+/// level: "debug" | "info" | "warn" | "error" (or RUST_LOG-style filter).
19
+/// format: "text" (default) | "json".
20
+#[pyfunction]
21
+#[pyo3(signature = (level="info", format="text"))]
22
+fn init_logging(level: &str, format: &str) -> PyResult<()> {
23
+ let fmt = match format.to_ascii_lowercase().as_str() {
24
+ "json" => LogFormat::Json,
25
+ _ => LogFormat::Text,
26
+ };
27
+ core_init_logging(&LoggingConfig {
28
+ format: fmt,
29
+ level: level.to_string(),
30
+ ..Default::default()
31
+ })
32
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
33
+}
34
+
35
+/// Initialize JSON-formatted logging to stdout.
36
+#[pyfunction]
37
+fn init_json_logging() -> PyResult<()> {
38
+ core_init_json().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
39
+}
40
+
41
+/// Initialize JSON logging to a rolling file path.
42
+#[pyfunction]
43
+fn init_file_logging(path: &str) -> PyResult<()> {
44
+ core_init_file(path).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
45
+}
46
+
47
+/// Initialize tracing with an OTLP gRPC endpoint (collector export).
48
+#[pyfunction]
49
+#[pyo3(signature = (endpoint="http://localhost:4317", service_name="justapi"))]
50
+fn init_otlp_tracing(endpoint: &str, service_name: &str) -> PyResult<()> {
51
+ core_init_otlp(endpoint, service_name)
52
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
53
+}
54
+
55
+/// Flush and shut down the tracing subscriber (e.g. before process exit).
56
+#[pyfunction]
57
+fn shutdown_tracing() {
58
+ core_shutdown();
59
+}
60
+
61
+pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
62
+ m.add_function(wrap_pyfunction!(init_logging, m)?)?;
63
+ m.add_function(wrap_pyfunction!(init_json_logging, m)?)?;
64
+ m.add_function(wrap_pyfunction!(init_file_logging, m)?)?;
65
+ m.add_function(wrap_pyfunction!(init_otlp_tracing, m)?)?;
66
+ m.add_function(wrap_pyfunction!(shutdown_tracing, m)?)?;
67
+ Ok(())
68
+}
0
69
▼crates/justapi-py/src/native/app.rs
+6-0
1350
1350
pyo3::exceptions::PyValueError::new_err(format!("Invalid address: {}", e))
1351
1351
})?;
1352
1352
1353
+ // Ensure a default logger is installed before serving, so that the
1354
+ // server's `tracing` events (listen address, per-request spans,
1355
+ // connection errors, graceful shutdown) are actually surfaced. A user
1356
+ // who called `justapi.init_logging(...)` first keeps their config.
1357
+ justapi_core::tracing_setup::init_default_if_unset();
1358
+
1353
1359
// Initialize the dedicated GIL pool once (detects GIL vs free-threaded
1354
1360
// mode automatically; see gil_pool.rs / DECISIONS.md ADR-049).
1355
1361
crate::gil_pool::init(py, None);
1356
1362
▼crates/justapi-py/src/test_client.rs
+5-0
182
182
self.do_request(py, move |client, rt| rt.block_on(client.put(&path, body)))
183
183
}
184
184
185
+ fn patch(&self, py: Python<'_>, path: &str, body: Vec<u8>) -> PyResult<Py<PyAny>> {
186
+ let path = path.to_owned();
187
+ self.do_request(py, move |client, rt| rt.block_on(client.patch(&path, body)))
188
+ }
189
+
185
190
fn delete(&self, py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
186
191
let path = path.to_owned();
187
192
self.do_request(py, move |client, rt| rt.block_on(client.delete(&path)))
188
193
▼demo_shop/README.md
+120-108
1
-# demo_shop — real-life e-commerce API (Olist dataset)
1
+# demo_shop — MVP e-commerce store (JustAPI Runtime)
2
2
3
-A realistic, complex online-shopping API built on the JustAPI Runtime, backed by
4
-the **Olist Brazilian e-commerce SQLite dataset** (`olist.sqlite`, ~112 MB:
5
-99k orders, 33k products, 3k sellers, 100k reviews, 1M geolocation rows).
3
+A **proper, working mini shop** built on the JustAPI Runtime: a real data model
4
+with full CRUD, a shopping-cart → checkout → order → payment → review flow,
5
+managed inventory, and analytics. Backed by a writable SQLite database
6
+(`shop.db`) seeded from the Olist Brazilian e-commerce dataset.
6
7
7
-The goal of this app is **not** to ship a product — it is to exercise the
8
-framework with a *real, join-heavy workload* (catalog, orders, sellers,
9
-analytics, search) and **surface framework problems** that the echo/hello
10
-micro-benchmarks hide. It succeeded: it found several serious issues.
8
+This is a self-contained MVP you can run, register products, put them in a cart,
9
+check out, and review — not just a read-only demo.
11
10
12
-## What's implemented
11
+## Data model
13
12
14
-| Area | Endpoints |
15
-|---|---|
16
-| Health/meta | `/shop/health`, `/shop/meta/tables` |
17
-| Catalog | `/products` (filter/search/paginate/sort), `/products/{id}`, `/products/{id}/reviews`, `/categories` |
18
-| Sellers | `/sellers`, `/sellers/{id}`, `/sellers/{id}/products` |
19
-| Customers | `/customers/{id}` |
20
-| Orders | `/orders` (filter), `/orders/{id}` (items+payments+reviews aggregate), `/orders/{id}/timeline` (delivery-delay calc) |
21
-| Reviews | `/reviews`, `/reviews/summary` |
22
-| Geolocation | `/geolocation/states` |
23
-| Analytics | `/analytics/sales-by-category`, `/analytics/top-sellers`, `/analytics/delivery-performance`, `/analytics/monthly-revenue` |
24
-| Search | `/search?q=&kind=` |
25
-
26
-All SQL runs through the Rust-native `DbPool` (`app.db.query(sql, params)`,
27
-injection-safe bound params). 23 endpoints validated green via the framework's
28
-`AsyncTestClient` (`pytest`-style smoke in `test_app.py`).
13
+```
14
+categories ─< products >─ sellers
15
+customers ─< carts >─ cart_items ─< products
16
+customers ─< orders >─ order_items ─< products, payments
17
+orders ─< reviews >─ products
18
+```
19
+
20
+`shop.db` is created and seeded on first run (`python db_setup.py`): 72
21
+categories, ~3.1k sellers, ~99k customers, and ~33k products (with managed
22
+`price` + `stock`), all imported from `olist.sqlite`.
23
+
24
+## Endpoints
25
+
26
+| Area | Method & path | Notes |
27
+|---|---|---|
28
+| Health | `GET /shop/status` | `{"health":"ok","products":N}` |
29
+| Categories | `GET /categories`, `POST /categories` | create w/ 409 on dup slug |
30
+| Products | `GET /products` (filter/search/sort/paginate), `GET /products/{id}`, `POST /products`, `PATCH /products/{id}`, `DELETE /products/{id}` | full CRUD |
31
+| Sellers | `GET /sellers/{id}` | + lifetime stats |
32
+| Customers | `GET /customers/{id}`, `POST /customers` | register |
33
+| Cart | `POST /carts`, `GET /carts/{id}`, `POST /carts/{id}/items`, `DELETE /carts/{id}/items/{product_id}` | line items, totals |
34
+| Checkout | `POST /carts/{id}/checkout` | **atomic txn**: creates order + payment, decrements stock, empties cart |
35
+| Orders | `GET /orders`, `GET /orders/{id}` (items + payments), `PATCH /orders/{id}/status` | status lifecycle pending→paid→shipped→delivered→cancelled |
36
+| Reviews | `POST /orders/{id}/reviews` | score 1–5, must be a product in the order |
37
+| Analytics | `GET /analytics/sales-by-category`, `GET /analytics/top-products`, `GET /analytics/review-summary` | over the owned, mutable tables |
38
+
39
+All request bodies are validated with `justapi.Schema` classes
40
+(`CategoryIn`, `ProductIn`, `ProductPatch`, `CustomerIn`, `CartItemIn`,
41
+`CheckoutIn`, `OrderStatusIn`, `ReviewIn`). Errors return proper HTTP status
42
+codes: `400` bad input, `404` not found, `409` conflict (duplicate / out of
43
+stock / wrong product), `422` validation failure.
29
44
30
45
## Run it
31
46
32
47
```bash
33
-# Serve (DB-backed app runs fine over TCP after ADR-068)
34
-python demo_shop/app.py --port 8080
48
+# 1) (first time) build + seed the owned shop database
49
+python db_setup.py
50
+
51
+# 2) serve (demo_shop is now a package, run as a module)
52
+/home/swadhin/RastAPI/crates/justapi-py/.venv/bin/python -m shop --port 8080
35
53
36
-# Smoke-test the routes (works — uses the test client, not the TCP server)
37
-python demo_shop/test_app.py
54
+# 3) exercise the full CRUD + checkout flow (no server needed)
55
+/home/swadhin/RastAPI/crates/justapi-py/.venv/bin/python test_app.py
56
+```
57
+
58
+> `uv run` does **not** work from this directory — it walks up into the parent
59
+> RastAPI `uv` workspace (which conflicts with the `SQLPILOT` member). Use the
60
+> maturin venv that has `justapi` installed, as shown above.
61
+
62
+### Example flow (curl)
38
63
39
-# In-process load test (see Finding #5)
40
-python demo_shop/bench_async.py --concurrency 1 --per-worker 20
64
+```bash
65
+# register a customer, create a cart
66
+curl -s -X POST localhost:8080/customers -d '{"id":"c1","city":"SP"}' -H 'content-type: application/json'
67
+curl -s -X POST localhost:8080/carts -d '{"customer_id":"c1"}' -H 'content-type: application/json'
68
+# -> {"id":"cart_xxx", ...}
69
+
70
+# add an item, check out
71
+curl -s -X POST localhost:8080/carts/cart_xxx/items -d '{"product_id":"00066f42...","quantity":2}' -H 'content-type: application/json'
72
+curl -s -X POST localhost:8080/carts/cart_xxx/checkout -d '{"method":"credit_card"}' -H 'content-type: application/json'
73
+# -> {"id":"order_xxx","order_status":"paid","total_amount": ...}
74
+
75
+# advance + review
76
+curl -s -X PATCH localhost:8080/orders/order_xxx/status -d '{"status":"shipped"}' -H 'content-type: application/json'
77
+curl -s -X POST localhost:8080/orders/order_xxx/reviews -d '{"product_id":"00066f42...","score":5,"title":"Great"}' -H 'content-type: application/json'
41
78
```
42
79
43
-## Framework problems found — ROOT-CAUSED & FIXED (ADR-068)
44
-
45
-This exercise set out to break the framework by building a real app on it. It
46
-found four genuine defects in the Python-handler + SQLite path. All four are now
47
-fixed (commit ADR-068) and verified against this app.
48
-
49
-### Finding #1 — `app.db` is `None` inside handlers unless the pool is connected
50
-`app.db` resolves the pool lazily; inside a request context the pool may be
51
-`None` (returns `AttributeError: 'NoneType' object has no attribute 'query'`).
52
-In practice the pool is not connected until a runtime calls `connect_database()`.
53
-**Status: by-design / documented.** Mitigation: call `app.connect_database()`
54
-eagerly (see `app.py`). Not a defect — kept as a usage note.
55
-
56
-### Finding #2 — `set_database(..., wal=True)` HANGS the pool connect forever — **FIXED**
57
-`app.set_database(url, wal=True)` appends `PRAGMA journal_mode=WAL`, which takes
58
-an **exclusive lock** on the SQLite file. The original `after_connect` ran the
59
-pragma with `conn.execute` and **no `busy_timeout`**, so when the pool warmed up
60
-several connections concurrently the second opener blocked on the WAL lock
61
-forever (no timeout → infinite wait). **Fix:** `justapi-core/src/db/pool.rs`
62
-now always prepends `PRAGMA busy_timeout=5000` for SQLite and runs every pragma
63
-(including `journal_mode=WAL`) via `fetch_optional`. Result: `wal=True` now
64
-connects in **0.00s** on the 112 MB Olist file (was a 30s+ hang).
65
-
66
-### Finding #3 — `app.run()` "deadlocks" on a configured database — **RETRACTED**
67
-Original report said a DB-backed `app.run()` "never binds." Re-testing after the
68
-D4 fix shows the server **always bound and served** — builtin `/health` and
69
-`/shop/meta/tables` returned correctly. The original "never up" probe hit
70
-`/shop/health`, a **DB-backed handler**, which deadlocked at the time (D4); the
71
-empty/hung response was misread as "server not up." Conclusion: there was no
72
-`run()` bind deadlock — the failure was entirely the D4 concurrency deadlock on
73
-DB-backed routes. No `run()`-specific fix was required.
74
-
75
-### Finding #4 — DB-backed handler ~900 ms/req at concurrency=1 — **FIXED**
76
-Root cause: `connect_database()` built a **`tokio::runtime::Runtime::new()`
77
-(current-thread)** runtime and stored its handle for `DbPool::query/execute` to
78
-`block_on`. A current-thread runtime's handle can only be driven from the thread
79
-that owns it; calling `block_on` on it from a **foreign thread** (the GIL-pool
80
-worker / server runtime thread) silently deadlocks or serializes catastrophically
81
-— producing ~900 ms/req at concurrency=1 and a full hang at concurrency>1.
82
-**Fix:** `connect_database()` now builds a **multi-threaded** runtime
83
-(`Builder::new_multi_thread().worker_threads(4).enable_all()`). A multi-thread
84
-runtime's handle is safe to `block_on` from **any** thread (it temporarily drives
85
-the runtime on the caller's thread). Result: concurrency=1 `/products` latency
86
-dropped from p50 910 ms to **2–4 ms** (a ~250× improvement), and the per-request
87
-path is now ~1–2 ms on top of the raw query, matching the DB's own speed.
88
-
89
-### Finding #5 — concurrent async load DEADLOCKS — **FIXED**
90
-Same root cause as #4 (current-thread `db_runtime` handle + foreign-thread
91
-`block_on`). Under concurrent in-flight requests the GIL pool spawned many callers
92
-on different threads, each `block_on`-ing the current-thread runtime → deadlock.
93
-**Fix:** the multi-threaded `db_runtime` (above) makes `block_on` from any thread
94
-safe. Verified: `oha -z 12s -c 64` against `/products?size=20` served **3,706
95
-concurrent requests** (p50 188 ms at 64-way GIL saturation, p99 367 ms, 0
96
-deadlocks). The 188 ms p50 under -c 64 is GIL-pool saturation across 64
97
-simultaneous Python handlers, not a per-request defect (at concurrency=1 it is
98
-2–4 ms).
99
-
100
-## Baseline: Rust HTTP core is fast; the Python+DB path is now fast too
101
-
102
-| Path | Throughput | Notes |
103
-|---|---:|---|
104
-| No-DB `app.run()` + `oha -c 64` | **37,171 req/s** | Rust HTTP + Python dict handler, real TCP |
105
-| DB-backed `/products`, `concurrency=1` | **~250–330 req/s** effective (2–4 ms/req) | framework handler+DB dispatch, post-fix |
106
-| DB-backed `/products`, `oha -c 64` | **314 req/s** (p50 188 ms, p99 367 ms) | GIL-pool saturation at 64-way, **no deadlock** |
107
-| Raw `app.db.query` (same queries) | ~1–100 ms/query | DB itself was always fine |
108
-| `wal=True` connect on 112 MB file | **0.00 s** | was a 30s+ hang before the fix |
109
-
110
-**Conclusion:** the runtime's networking core was always solid (37k req/s). The
111
-Python-handler + SQLite path — the path every real app uses — had two genuine
112
-bugs (D2 WAL hang, D4 current-thread-runtime deadlock) both now fixed in
113
-`justapi-core`/`justapi-py`. After ADR-068 the DB-backed app serves real
114
-concurrent traffic over TCP with single-digit-ms latency. The only remaining
115
-headroom is GIL-pool serialization under very high concurrency, which is inherent
116
-to running Python handlers and is independent of the DB path.
80
+## Interactive API docs (Scalar)
81
+
82
+The app ships interactive OpenAPI docs, generated live from the registered
83
+routes (`/openapi.json`):
84
+
85
+| URL | Viewer |
86
+|---|---|
87
+| **`/`** (home page) | **Scalar API Reference** — opens by default |
88
+| `/scalar` | Scalar API Reference |
89
+| `/docs` | Swagger UI |
90
+| `/redoc` | ReDoc |
91
+| `/openapi.json` | Raw OpenAPI 3.1 spec |
92
+
93
+Open <http://localhost:8080/> in a browser to see the full, interactive
94
+reference of every endpoint (catalog, cart, checkout, orders, reviews,
95
+analytics).
96
+
97
+## Framework gotchas discovered while building this MVP
98
+
99
+Two real framework behaviors bit during development (workarounds applied in
100
+`app.py`):
101
+
102
+1. **Response bodies containing a top-level `"status"` key are emitted empty.**
103
+ A handler returning `{"status": "ok", ...}` produces a `200` with an empty
104
+ body; `{"products": N}` is fine. Workaround: order responses expose the
105
+ order state under `order_status` (the SQL column is still `status`), and the
106
+ health endpoint uses `health` instead of `status`. *(Framework bug — should
107
+ be filed.)*
108
+
109
+2. **`body_schema=` delivers the validated payload to the handler as raw
110
+ `bytes`, not an instantiated Schema object.** So handlers parse
111
+ `request.json()` and validate via the local `validate(schema, data)` helper
112
+ (which uses the Schema's generated JSON Schema). The `Schema` classes still
113
+ drive OpenAPI docs via `body_schema=`. *(Framework bug — should be filed.)*
114
+
115
+Neither blocks the MVP; both are noted for a follow-up framework fix.
117
116
118
117
## Files
119
-- `app.py` — the API (23 routes, SQLite-backed).
120
-- `test_app.py` — smoke test, 23 endpoints (all pass via `AsyncTestClient`).
121
-- `bench_async.py` — in-process load test (surfaces Finding #4/#5).
122
-- `README.md` — this file.
118
+
119
+The app is a small **package** (`shop/`), not a monolith:
120
+
121
+- `db_setup.py` — owned schema (`shop.db`) + one-shot seed from `olist.sqlite`.
122
+- `shop/__init__.py` — package doc, re-exports the `app` instance.
123
+- `shop/config.py` — `create_app()`, DB wiring, package-level `app`/`db` singletons.
124
+- `shop/db.py` — SQL helpers (`q`, `q1`, `paginate`, `new_token`).
125
+- `shop/schemas.py` — `Schema` request classes + the `validate()` helper.
126
+- `shop/catalog.py` — categories, products (full CRUD), sellers.
127
+- `shop/customers.py` — customer register / lookup.
128
+- `shop/cart.py` — cart lifecycle + `checkout` (atomic order + payment + stock dec).
129
+- `shop/orders.py` — orders list / detail / status lifecycle.
130
+- `shop/reviews.py` — reviews + analytics.
131
+- `shop/cli.py` — `python -m shop` entrypoint (`--host`/`--port`).
132
+- `test_app.py` — end-to-end flow test via `justapi.testing.AsyncTestClient`
133
+ (29 assertions: catalog, CRUD, checkout txn, stock decrement, status
134
+ lifecycle, reviews, validation).
123
135
▼demo_shop/app.py
+0-502
1
-"""demo_shop — a realistic e-commerce API built on the JustAPI Runtime.
2
-
3
-Uses the Olist Brazilian e-commerce dataset (demo_shop/olist.sqlite) as a
4
-read-mostly production-like shop: products, sellers, customers, orders,
5
-payments, reviews, geolocation, plus analytics aggregations.
6
-
7
-Goal of this app (per project direction): exercise the framework with a
8
-*complex, real-life* workload rather than micro-benchmarks, and surface
9
-framework problems (serialization, DB pool, GIL, joins, pagination, errors).
10
-
11
-Run:
12
- python demo_shop/app.py # serves on 127.0.0.1:8080
13
- python demo_shop/app.py --port 8080
14
-"""
15
-from __future__ import annotations
16
-
17
-import argparse
18
-import json
19
-import os
20
-import time
21
-from typing import Any, Optional
22
-
23
-from justapi import (
24
- JustAPIApp,
25
- Query,
26
- Path,
27
- Schema,
28
- Request,
29
- HTTPException,
30
-)
31
-
32
-HERE = os.path.dirname(os.path.abspath(__file__))
33
-DB_PATH = os.environ.get(
34
- "DEMO_SHOP_DB", os.path.join(HERE, "olist.sqlite")
35
-)
36
-assert os.path.exists(DB_PATH), f"DB not found: {DB_PATH}"
37
-
38
-
39
-app = JustAPIApp()
40
-
41
-# Rust-native connection pool over SQLite.
42
-# NOTE: app.set_database(..., wal=True) HANGS the pool connect on a real file
43
-# (framework bug — see demo_shop/README findings). Use plain pool; SQLite
44
-# still serves reads concurrently well enough for this demo.
45
-app.set_database(f"sqlite://{DB_PATH}")
46
-# Connect eagerly at import time. This is a WORKAROUND for a framework deadlock:
47
-# app.run() calls connect_database() from inside its own runtime context and
48
-# hangs forever on a configured DB. Connecting here first makes db_pool.is_some()
49
-# true so run() skips that call (see demo_shop/README, Finding #4).
50
-app.connect_database()
51
-
52
-
53
-
54
-# ---------------------------------------------------------------------------
55
-# Small helpers
56
-# ---------------------------------------------------------------------------
57
-def rows_to_dicts(rows: list) -> list:
58
- """Convert sqlx rows (list of dicts already?) to plain dicts."""
59
- out = []
60
- for r in rows:
61
- if isinstance(r, dict):
62
- out.append(r)
63
- elif hasattr(r, "as_dict"):
64
- out.append(r.as_dict())
65
- else:
66
- out.append(dict(r))
67
- return out
68
-
69
-
70
-def query_one(sql: str, params: list | None = None) -> dict | None:
71
- """Fetch a single row (helper over app.db.query, which has no query_one)."""
72
- rows = app.db.query(sql, params or [])
73
- rows = rows_to_dicts(rows)
74
- return rows[0] if rows else None
75
-
76
-
77
-def _page(qp) -> tuple[int, int]:
78
- try:
79
- page = max(1, int(qp.get("page", "1")))
80
- except (TypeError, ValueError):
81
- page = 1
82
- try:
83
- size = max(1, min(200, int(qp.get("size", "20"))))
84
- except (TypeError, ValueError):
85
- size = 20
86
- return page, size
87
-
88
-
89
-def paginate_query(base_sql: str, count_sql: str, params: list, qp) -> dict:
90
- page, size = _page(qp)
91
- offset = (page - 1) * size
92
- total = query_one(count_sql, params)
93
- total_n = int(total["c"]) if total and "c" in total else 0
94
- rows = app.db.query(base_sql + f" LIMIT {size} OFFSET {offset}", params)
95
- return {
96
- "page": page,
97
- "size": size,
98
- "total": total_n,
99
- "pages": (total_n + size - 1) // size if total_n else 0,
100
- "items": rows_to_dicts(rows),
101
- }
102
-
103
-
104
-# ---------------------------------------------------------------------------
105
-# Health / meta
106
-# ---------------------------------------------------------------------------
107
-@app.get("/shop/health")
108
-def shop_health(request):
109
- n = query_one("SELECT COUNT(*) AS c FROM products")
110
- return {"status": "ok", "db": "sqlite", "dataset": "olist", "products": n["c"]}
111
-
112
-
113
-@app.get("/shop/meta/tables")
114
-def meta_tables(request):
115
- rows = app.db.query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
116
- return {"tables": [r["name"] for r in rows]}
117
-
118
-
119
-# ---------------------------------------------------------------------------
120
-# Catalog: products
121
-# ---------------------------------------------------------------------------
122
-@app.get("/products")
123
-def list_products(request):
124
- qp = request.query_params
125
- cat = qp.get("category")
126
- q = qp.get("q")
127
- min_price = qp.get("min_price")
128
- max_price = qp.get("max_price")
129
- sort = qp.get("sort", "price") # price | reviews | random
130
- order = qp.get("order", "asc")
131
-
132
- wheres = []
133
- params: list = []
134
- if cat:
135
- wheres.append("p.product_category_name = ?")
136
- params.append(cat)
137
- if q:
138
- wheres.append("(p.product_category_name LIKE ? OR p.product_id LIKE ?)")
139
- params.append(f"%{q}%")
140
- params.append(f"%{q}%")
141
- if min_price:
142
- wheres.append("p.price >= ?")
143
- params.append(float(min_price))
144
- if max_price:
145
- wheres.append("p.price <= ?")
146
- params.append(float(max_price))
147
-
148
- where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
149
- allowed_sort = {"price": "p.product_id", "reviews": "p.product_id", "random": "p.product_id"}
150
- sort_col = allowed_sort.get(sort, "p.product_id")
151
- order_dir = "DESC" if order == "desc" else "ASC"
152
-
153
- base = (
154
- "SELECT p.product_id, p.product_category_name, p.product_weight_g, "
155
- "p.product_length_cm, p.product_height_cm, p.product_width_cm "
156
- f"FROM products p{where} ORDER BY {sort_col} {order_dir}"
157
- )
158
- count = f"SELECT COUNT(*) AS c FROM products p{where}"
159
- return paginate_query(base, count, params, qp)
160
-
161
-
162
-@app.get("/products/{product_id}")
163
-def product_detail(request, product_id: str):
164
- row = query_one(
165
- "SELECT * FROM products WHERE product_id = ?", [product_id]
166
- )
167
- if not row:
168
- raise HTTPException(status_code=404, detail="product not found")
169
- # Attach sales stats: how many times sold, avg review of its orders.
170
- stats = query_one(
171
- "SELECT COUNT(*) AS times_ordered, COALESCE(SUM(oi.price),0) AS revenue "
172
- "FROM order_items oi WHERE oi.product_id = ?",
173
- [product_id],
174
- )
175
- row = dict(row)
176
- row["sales_stats"] = stats
177
- return row
178
-
179
-
180
-@app.get("/products/{product_id}/reviews")
181
-def product_reviews(request, product_id: str):
182
- base = (
183
- "SELECT r.review_id, r.review_score, r.review_comment_title, "
184
- "r.review_comment_message, r.review_creation_date, oi.order_id "
185
- "FROM order_reviews r "
186
- "JOIN order_items oi ON oi.order_id = r.order_id "
187
- "WHERE oi.product_id = ? ORDER BY r.review_creation_date DESC"
188
- )
189
- count = (
190
- "SELECT COUNT(*) AS c FROM order_reviews r "
191
- "JOIN order_items oi ON oi.order_id = r.order_id WHERE oi.product_id = ?"
192
- )
193
- return paginate_query(base, count, [product_id], request.query_params)
194
-
195
-
196
-@app.get("/categories")
197
-def list_categories(request):
198
- rows = app.db.query(
199
- "SELECT t.product_category_name AS name, t.product_category_name_english AS name_en, "
200
- "(SELECT COUNT(*) FROM products p WHERE p.product_category_name = t.product_category_name) AS product_count "
201
- "FROM product_category_name_translation t ORDER BY product_count DESC"
202
- )
203
- return {"items": rows_to_dicts(rows)}
204
-
205
-
206
-# ---------------------------------------------------------------------------
207
-# Sellers
208
-# ---------------------------------------------------------------------------
209
-@app.get("/sellers")
210
-def list_sellers(request):
211
- base = (
212
- "SELECT s.seller_id, s.seller_city, s.seller_state, "
213
- "COUNT(DISTINCT oi.order_id) AS order_count, "
214
- "COUNT(oi.order_item_id) AS item_count, "
215
- "COALESCE(SUM(oi.price),0) AS gmv "
216
- "FROM sellers s "
217
- "LEFT JOIN order_items oi ON oi.seller_id = s.seller_id "
218
- "GROUP BY s.seller_id, s.seller_city, s.seller_state"
219
- )
220
- count = "SELECT COUNT(*) AS c FROM sellers"
221
- return paginate_query(base, count, [], request.query_params)
222
-
223
-
224
-@app.get("/sellers/{seller_id}")
225
-def seller_detail(request, seller_id: str):
226
- row = query_one("SELECT * FROM sellers WHERE seller_id = ?", [seller_id])
227
- if not row:
228
- raise HTTPException(status_code=404, detail="seller not found")
229
- stats = query_one(
230
- "SELECT COUNT(DISTINCT oi.order_id) AS orders, "
231
- "COALESCE(SUM(oi.price),0) AS gmv, "
232
- "COALESCE(AVG(r.review_score),0) AS avg_review, "
233
- "COUNT(r.review_id) AS reviews "
234
- "FROM order_items oi "
235
- "LEFT JOIN order_reviews r ON r.order_id = oi.order_id "
236
- "WHERE oi.seller_id = ?",
237
- [seller_id],
238
- )
239
- row = dict(row)
240
- row["stats"] = stats
241
- return row
242
-
243
-
244
-@app.get("/sellers/{seller_id}/products")
245
-def seller_products(request, seller_id: str):
246
- base = (
247
- "SELECT DISTINCT p.product_id, p.product_category_name "
248
- "FROM order_items oi JOIN products p ON p.product_id = oi.product_id "
249
- "WHERE oi.seller_id = ? ORDER BY p.product_id"
250
- )
251
- count = (
252
- "SELECT COUNT(DISTINCT oi.product_id) AS c FROM order_items oi "
253
- "WHERE oi.seller_id = ?"
254
- )
255
- return paginate_query(base, count, [seller_id], request.query_params)
256
-
257
-
258
-# ---------------------------------------------------------------------------
259
-# Customers
260
-# ---------------------------------------------------------------------------
261
-@app.get("/customers/{customer_id}")
262
-def customer_detail(request, customer_id: str):
263
- row = query_one(
264
- "SELECT * FROM customers WHERE customer_id = ?", [customer_id]
265
- )
266
- if not row:
267
- raise HTTPException(status_code=404, detail="customer not found")
268
- orders = app.db.query(
269
- "SELECT order_id, order_status, order_purchase_timestamp, "
270
- "order_estimated_delivery_date FROM orders WHERE customer_id = ? "
271
- "ORDER BY order_purchase_timestamp DESC",
272
- [customer_id],
273
- )
274
- row = dict(row)
275
- row["orders"] = rows_to_dicts(orders)
276
- return row
277
-
278
-
279
-# ---------------------------------------------------------------------------
280
-# Orders (the core transaction aggregate)
281
-# ---------------------------------------------------------------------------
282
-@app.get("/orders")
283
-def list_orders(request):
284
- qp = request.query_params
285
- status = qp.get("status")
286
- customer = qp.get("customer_id")
287
- wheres = []
288
- params: list = []
289
- if status:
290
- wheres.append("o.order_status = ?")
291
- params.append(status)
292
- if customer:
293
- wheres.append("o.customer_id = ?")
294
- params.append(customer)
295
- where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
296
- base = (
297
- "SELECT o.order_id, o.customer_id, o.order_status, "
298
- "o.order_purchase_timestamp, o.order_estimated_delivery_date "
299
- f"FROM orders o{where} ORDER BY o.order_purchase_timestamp DESC"
300
- )
301
- count = f"SELECT COUNT(*) AS c FROM orders o{where}"
302
- return paginate_query(base, count, params, qp)
303
-
304
-
305
-@app.get("/orders/{order_id}")
306
-def order_detail(request, order_id: str):
307
- order = query_one("SELECT * FROM orders WHERE order_id = ?", [order_id])
308
- if not order:
309
- raise HTTPException(status_code=404, detail="order not found")
310
- order = dict(order)
311
- order["items"] = rows_to_dicts(
312
- app.db.query(
313
- "SELECT order_item_id, product_id, seller_id, shipping_limit_date, "
314
- "price, freight_value FROM order_items WHERE order_id = ? ORDER BY order_item_id",
315
- [order_id],
316
- )
317
- )
318
- order["payments"] = rows_to_dicts(
319
- app.db.query(
320
- "SELECT payment_sequential, payment_type, payment_installments, "
321
- "payment_value FROM order_payments WHERE order_id = ? "
322
- "ORDER BY payment_sequential",
323
- [order_id],
324
- )
325
- )
326
- order["reviews"] = rows_to_dicts(
327
- app.db.query(
328
- "SELECT review_id, review_score, review_comment_title, "
329
- "review_comment_message FROM order_reviews WHERE order_id = ?",
330
- [order_id],
331
- )
332
- )
333
- return order
334
-
335
-
336
-@app.get("/orders/{order_id}/timeline")
337
-def order_timeline(request, order_id: str):
338
- order = query_one(
339
- "SELECT order_purchase_timestamp, order_approved_at, "
340
- "order_delivered_carrier_date, order_delivered_customer_date, "
341
- "order_estimated_delivery_date, order_status FROM orders WHERE order_id = ?",
342
- [order_id],
343
- )
344
- if not order:
345
- raise HTTPException(status_code=404, detail="order not found")
346
- o = dict(order)
347
- est = o.get("order_estimated_delivery_date")
348
- deliv = o.get("order_delivered_customer_date")
349
- if est and deliv:
350
- try:
351
- fmt = "%Y-%m-%d %H:%M:%S"
352
- delta = (
353
- time.mktime(time.strptime(deliv, fmt))
354
- - time.mktime(time.strptime(est, fmt))
355
- )
356
- o["delivery_delay_days"] = round(delta / 86400.0, 2)
357
- except Exception:
358
- o["delivery_delay_days"] = None
359
- return o
360
-
361
-
362
-# ---------------------------------------------------------------------------
363
-# Reviews
364
-# ---------------------------------------------------------------------------
365
-@app.get("/reviews")
366
-def list_reviews(request):
367
- base = (
368
- "SELECT r.review_id, r.order_id, r.review_score, r.review_comment_title, "
369
- "r.review_comment_message, r.review_creation_date "
370
- "FROM order_reviews r ORDER BY r.review_creation_date DESC"
371
- )
372
- count = "SELECT COUNT(*) AS c FROM order_reviews"
373
- return paginate_query(base, count, [], request.query_params)
374
-
375
-
376
-@app.get("/reviews/summary")
377
-def reviews_summary(request):
378
- row = query_one(
379
- "SELECT COUNT(*) AS total, AVG(review_score) AS avg_score, "
380
- "SUM(CASE WHEN review_score >= 4 THEN 1 ELSE 0 END) AS positive, "
381
- "SUM(CASE WHEN review_score <= 2 THEN 1 ELSE 0 END) AS negative "
382
- "FROM order_reviews"
383
- )
384
- return row
385
-
386
-
387
-# ---------------------------------------------------------------------------
388
-# Geolocation
389
-# ---------------------------------------------------------------------------
390
-@app.get("/geolocation/states")
391
-def geo_states(request):
392
- rows = app.db.query(
393
- "SELECT geolocation_state AS state, COUNT(*) AS cnt, "
394
- "AVG(geolocation_lat) AS avg_lat, AVG(geolocation_lng) AS avg_lng "
395
- "FROM geolocation GROUP BY geolocation_state ORDER BY cnt DESC"
396
- )
397
- return {"items": rows_to_dicts(rows)}
398
-
399
-
400
-# ---------------------------------------------------------------------------
401
-# Analytics / aggregations (the heavy, join-heavy endpoints)
402
-# ---------------------------------------------------------------------------
403
-@app.get("/analytics/sales-by-category")
404
-def analytics_sales_by_category(request):
405
- rows = app.db.query(
406
- "SELECT p.product_category_name AS category, "
407
- "COUNT(oi.order_item_id) AS units, "
408
- "COALESCE(SUM(oi.price),0) AS revenue, "
409
- "COALESCE(SUM(oi.freight_value),0) AS freight "
410
- "FROM order_items oi JOIN products p ON p.product_id = oi.product_id "
411
- "GROUP BY p.product_category_name ORDER BY revenue DESC"
412
- )
413
- return {"items": rows_to_dicts(rows)}
414
-
415
-
416
-@app.get("/analytics/top-sellers")
417
-def analytics_top_sellers(request):
418
- qp = request.query_params
419
- try:
420
- limit = max(1, min(100, int(qp.get("limit", "20"))))
421
- except (TypeError, ValueError):
422
- limit = 20
423
- rows = app.db.query(
424
- "SELECT s.seller_id, s.seller_state, "
425
- "COUNT(DISTINCT oi.order_id) AS orders, "
426
- "COUNT(oi.order_item_id) AS items, "
427
- "COALESCE(SUM(oi.price),0) AS gmv, "
428
- "COALESCE(AVG(r.review_score),0) AS avg_review "
429
- "FROM sellers s "
430
- "JOIN order_items oi ON oi.seller_id = s.seller_id "
431
- "LEFT JOIN order_reviews r ON r.order_id = oi.order_id "
432
- "GROUP BY s.seller_id, s.seller_state "
433
- "ORDER BY gmv DESC LIMIT ?",
434
- [limit],
435
- )
436
- return {"items": rows_to_dicts(rows)}
437
-
438
-
439
-@app.get("/analytics/delivery-performance")
440
-def analytics_delivery(request):
441
- rows = app.db.query(
442
- "SELECT order_status, COUNT(*) AS cnt, "
443
- "COALESCE(AVG(CAST(julianday(order_delivered_customer_date) - julianday(order_purchase_timestamp) AS REAL)),0) AS avg_days "
444
- "FROM orders WHERE order_delivered_customer_date IS NOT NULL "
445
- "GROUP BY order_status"
446
- )
447
- return {"items": rows_to_dicts(rows)}
448
-
449
-
450
-@app.get("/analytics/monthly-revenue")
451
-def analytics_monthly(request):
452
- rows = app.db.query(
453
- "SELECT substr(o.order_purchase_timestamp,1,7) AS month, "
454
- "COUNT(DISTINCT o.order_id) AS orders, "
455
- "COALESCE(SUM(oi.price),0) AS revenue "
456
- "FROM orders o JOIN order_items oi ON oi.order_id = o.order_id "
457
- "GROUP BY month ORDER BY month"
458
- )
459
- return {"items": rows_to_dicts(rows)}
460
-
461
-
462
-# ---------------------------------------------------------------------------
463
-# Search (full-text-ish across products + sellers)
464
-# ---------------------------------------------------------------------------
465
-@app.get("/search")
466
-def search(request):
467
- qp = request.query_params
468
- q = qp.get("q", "")
469
- kind = qp.get("kind", "all") # all | product | seller
470
- if not q:
471
- raise HTTPException(status_code=400, detail="missing ?q=")
472
- like = f"%{q}%"
473
- result = {"products": [], "sellers": []}
474
- if kind in ("all", "product"):
475
- result["products"] = rows_to_dicts(
476
- app.db.query(
477
- "SELECT product_id, product_category_name FROM products "
478
- "WHERE product_id LIKE ? OR product_category_name LIKE ? LIMIT 25",
479
- [like, like],
480
- )
481
- )
482
- if kind in ("all", "seller"):
483
- result["sellers"] = rows_to_dicts(
484
- app.db.query(
485
- "SELECT seller_id, seller_city, seller_state FROM sellers "
486
- "WHERE seller_city LIKE ? OR seller_state LIKE ? LIMIT 25",
487
- [like, like],
488
- )
489
- )
490
- return result
491
-
492
-
493
-def main():
494
- parser = argparse.ArgumentParser()
495
- parser.add_argument("--host", default="127.0.0.1")
496
- parser.add_argument("--port", type=int, default=8080)
497
- args = parser.parse_args()
498
- app.run(f"{args.host}:{args.port}")
499
-
500
-
501
-if __name__ == "__main__":
502
- main()
503
0
▼demo_shop/db_setup.py
+244-0
1
+"""db_setup — owned application database for the MVP shop.
2
+
3
+The Olist `olist.sqlite` is a large *read-only* historical dataset. To build a
4
+real MVP with create/update/delete operations we keep a separate, *owned*
5
+SQLite database (`shop.db`) that we control. On first run it is created and
6
+seeded from Olist (categories, sellers, customers, and a product catalog with
7
+managed stock/price), then all mutations happen against `shop.db`.
8
+
9
+Run directly to (re)build/seed: python db_setup.py
10
+"""
11
+from __future__ import annotations
12
+
13
+import os
14
+import sqlite3
15
+import time
16
+
17
+HERE = os.path.dirname(os.path.abspath(__file__))
18
+OLIST_DB = os.environ.get("DEMO_SHOP_OLIST", os.path.join(HERE, "olist.sqlite"))
19
+SHOP_DB = os.environ.get("DEMO_SHOP_DB", os.path.join(HERE, "shop.db"))
20
+
21
+SCHEMA = """
22
+PRAGMA foreign_keys = ON;
23
+
24
+CREATE TABLE IF NOT EXISTS categories (
25
+ id INTEGER PRIMARY KEY,
26
+ slug TEXT UNIQUE NOT NULL,
27
+ name_pt TEXT NOT NULL,
28
+ name_en TEXT
29
+);
30
+
31
+CREATE TABLE IF NOT EXISTS sellers (
32
+ id TEXT PRIMARY KEY, -- original Olist seller_id
33
+ city TEXT,
34
+ state TEXT,
35
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
36
+);
37
+
38
+CREATE TABLE IF NOT EXISTS customers (
39
+ id TEXT PRIMARY KEY, -- original Olist customer_id
40
+ unique_id TEXT,
41
+ city TEXT,
42
+ state TEXT,
43
+ email TEXT,
44
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
45
+);
46
+
47
+CREATE TABLE IF NOT EXISTS products (
48
+ id TEXT PRIMARY KEY, -- original Olist product_id
49
+ category_id INTEGER REFERENCES categories(id),
50
+ name_pt TEXT,
51
+ weight_g REAL,
52
+ length_cm REAL,
53
+ height_cm REAL,
54
+ width_cm REAL,
55
+ price REAL NOT NULL DEFAULT 0, -- managed selling price
56
+ stock INTEGER NOT NULL DEFAULT 0, -- managed inventory
57
+ active INTEGER NOT NULL DEFAULT 1,
58
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
59
+);
60
+
61
+CREATE TABLE IF NOT EXISTS carts (
62
+ id TEXT PRIMARY KEY, -- public cart token
63
+ customer_id TEXT REFERENCES customers(id),
64
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
65
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
66
+);
67
+
68
+CREATE TABLE IF NOT EXISTS cart_items (
69
+ cart_id TEXT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
70
+ product_id TEXT NOT NULL REFERENCES products(id),
71
+ quantity INTEGER NOT NULL DEFAULT 1,
72
+ PRIMARY KEY (cart_id, product_id)
73
+);
74
+
75
+CREATE TABLE IF NOT EXISTS orders (
76
+ id TEXT PRIMARY KEY, -- public order token
77
+ customer_id TEXT NOT NULL REFERENCES customers(id),
78
+ status TEXT NOT NULL DEFAULT 'pending', -- pending|paid|shipped|delivered|cancelled
79
+ total_amount REAL NOT NULL DEFAULT 0,
80
+ shipping_amount REAL NOT NULL DEFAULT 0,
81
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
82
+ paid_at TEXT,
83
+ shipped_at TEXT,
84
+ delivered_at TEXT
85
+);
86
+
87
+CREATE TABLE IF NOT EXISTS order_items (
88
+ order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
89
+ product_id TEXT NOT NULL REFERENCES products(id),
90
+ seller_id TEXT REFERENCES sellers(id),
91
+ quantity INTEGER NOT NULL DEFAULT 1,
92
+ unit_price REAL NOT NULL,
93
+ freight REAL NOT NULL DEFAULT 0,
94
+ PRIMARY KEY (order_id, product_id)
95
+);
96
+
97
+CREATE TABLE IF NOT EXISTS payments (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
100
+ method TEXT NOT NULL, -- credit_card|voucher|debit_card|boleto
101
+ installments INTEGER NOT NULL DEFAULT 1,
102
+ amount REAL NOT NULL,
103
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
104
+);
105
+
106
+CREATE TABLE IF NOT EXISTS reviews (
107
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
108
+ order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
109
+ product_id TEXT REFERENCES products(id),
110
+ customer_id TEXT REFERENCES customers(id),
111
+ score INTEGER NOT NULL CHECK (score BETWEEN 1 AND 5),
112
+ title TEXT,
113
+ comment TEXT,
114
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
115
+);
116
+"""
117
+
118
+STATUSES = {"pending", "paid", "shipped", "delivered", "cancelled"}
119
+
120
+
121
+def open_shop() -> sqlite3.Connection:
122
+ con = sqlite3.connect(SHOP_DB)
123
+ con.row_factory = sqlite3.Row
124
+ con.execute("PRAGMA foreign_keys = ON")
125
+ return con
126
+
127
+
128
+def seed_from_olist(force: bool = False) -> None:
129
+ """Create the schema and populate it from the Olist dataset if empty."""
130
+ con = open_shop()
131
+ con.executescript(SCHEMA)
132
+
133
+ cur = con.execute("SELECT COUNT(*) AS c FROM products")
134
+ if cur.fetchone()["c"] > 0 and not force:
135
+ con.close()
136
+ return
137
+
138
+ if force:
139
+ for t in ("reviews", "payments", "order_items", "orders",
140
+ "cart_items", "carts", "products", "customers",
141
+ "sellers", "categories"):
142
+ con.execute(f"DELETE FROM {t}")
143
+
144
+ olist = sqlite3.connect(OLIST_DB)
145
+ olist.row_factory = sqlite3.Row
146
+
147
+ con.execute("BEGIN")
148
+
149
+ # Categories (portuguese -> english translation table)
150
+ cat_map: dict[str, int] = {}
151
+ for r in olist.execute(
152
+ "SELECT product_category_name, product_category_name_english "
153
+ "FROM product_category_name_translation"
154
+ ):
155
+ name_pt = r["product_category_name"]
156
+ slug = (name_pt or "unknown").replace(" ", "-").lower()
157
+ cur = con.execute(
158
+ "INSERT INTO categories (slug, name_pt, name_en) VALUES (?,?,?) "
159
+ "ON CONFLICT(slug) DO UPDATE SET name_pt=excluded.name_pt",
160
+ (slug, name_pt, r["product_category_name_english"]),
161
+ )
162
+ cat_map[name_pt] = cur.lastrowid
163
+ # fallback category for NULL names
164
+ cur = con.execute(
165
+ "INSERT INTO categories (slug, name_pt, name_en) VALUES ('unknown','unknown','Unknown') "
166
+ "ON CONFLICT(slug) DO UPDATE SET name_pt=excluded.name_pt"
167
+ )
168
+ cat_map[None] = cur.lastrowid
169
+
170
+ # Sellers
171
+ con.executemany(
172
+ "INSERT INTO sellers (id, city, state) VALUES (?,?,?) "
173
+ "ON CONFLICT(id) DO NOTHING",
174
+ [
175
+ (r["seller_id"], r["seller_city"], r["seller_state"])
176
+ for r in olist.execute("SELECT seller_id, seller_city, seller_state FROM sellers")
177
+ ],
178
+ )
179
+
180
+ # Customers
181
+ con.executemany(
182
+ "INSERT INTO customers (id, unique_id, city, state) VALUES (?,?,?,?) "
183
+ "ON CONFLICT(id) DO NOTHING",
184
+ [
185
+ (r["customer_id"], r["customer_unique_id"],
186
+ r["customer_city"], r["customer_state"])
187
+ for r in olist.execute(
188
+ "SELECT customer_id, customer_unique_id, customer_city, customer_state "
189
+ "FROM customers"
190
+ )
191
+ ],
192
+ )
193
+
194
+ # Products: seed catalog with stock. Price = historical avg order_item price
195
+ # for that product (one GROUP BY pass, not a per-row subquery). NULL -> a
196
+ # deterministic placeholder. Stock is a deterministic 20..199 per product.
197
+ avg_prices = {
198
+ row["product_id"]: row["a"]
199
+ for row in olist.execute(
200
+ "SELECT product_id, AVG(price) AS a FROM order_items GROUP BY product_id"
201
+ )
202
+ }
203
+ product_rows = []
204
+ for r in olist.execute(
205
+ "SELECT product_id, product_category_name, product_weight_g, "
206
+ "product_length_cm, product_height_cm, product_width_cm FROM products"
207
+ ):
208
+ pid = r["product_id"]
209
+ avg = avg_prices.get(pid)
210
+ price = round(avg, 2) if avg else round((hash(pid) % 9000) / 100.0 + 9.9, 2)
211
+ stock = 20 + (hash(pid) % 180) # 20..199 deterministic
212
+ product_rows.append((
213
+ pid,
214
+ cat_map.get(r["product_category_name"], cat_map[None]),
215
+ r["product_category_name"],
216
+ r["product_weight_g"],
217
+ r["product_length_cm"],
218
+ r["product_height_cm"],
219
+ r["product_width_cm"],
220
+ price,
221
+ stock,
222
+ ))
223
+ con.executemany(
224
+ "INSERT INTO products (id, category_id, name_pt, weight_g, length_cm, "
225
+ "height_cm, width_cm, price, stock) VALUES (?,?,?,?,?,?,?,?,?) "
226
+ "ON CONFLICT(id) DO NOTHING",
227
+ product_rows,
228
+ )
229
+
230
+ con.commit()
231
+ print(
232
+ f"seeded shop.db: {len(cat_map)} categories, "
233
+ f"{con.execute('SELECT COUNT(*) c FROM sellers').fetchone()['c']} sellers, "
234
+ f"{con.execute('SELECT COUNT(*) c FROM customers').fetchone()['c']} customers, "
235
+ f"{con.execute('SELECT COUNT(*) c FROM products').fetchone()['c']} products"
236
+ )
237
+ con.close()
238
+ olist.close()
239
+
240
+
241
+if __name__ == "__main__":
242
+ t0 = time.time()
243
+ seed_from_olist(force="--force" in __import__("sys").argv)
244
+ print(f"done in {time.time()-t0:.2f}s")
0
245
▼demo_shop/pyproject.toml
+11-0
1
+[project]
2
+name = "demo-shop"
3
+version = "0.1.0"
4
+description = "JustAPI demo e-commerce API on the Olist SQLite dataset"
5
+requires-python = ">=3.11"
6
+dependencies = ["justapi"]
7
+
8
+[tool.uv]
9
+# Keep this directory a standalone project so `uv run` does not walk up into
10
+# the parent RastAPI uv workspace (which conflicts with the SQLPILOT member).
11
+package = false
0
12
▼demo_shop/shop.db
+0-0
Binary file not shown.
▼demo_shop/shop/__init__.py
+24-0
1
+"""shop — a proper MVP e-commerce store API built on the JustAPI Runtime.
2
+
3
+A real mini shop: categories, products (managed stock & price), sellers,
4
+customers, a cart, checkout that creates orders + payments and decrements
5
+inventory, and customer reviews. Data lives in `shop.db` (created/seeded by
6
+`db_setup.py` from the Olist dataset). Mutations use real SQL transactions.
7
+
8
+Package layout
9
+--------------
10
+ shop/config.py application instance + DB wiring (singletons)
11
+ shop/db.py SQL helpers (query / paginate / tokens)
12
+ shop/schemas.py request validation schemas + validate() helper
13
+ shop/catalog.py categories + products CRUD + sellers
14
+ shop/customers.py customer register/lookup
15
+ shop/cart.py cart lifecycle + checkout (order + payment + stock dec)
16
+ shop/orders.py orders list/detail/status lifecycle
17
+ shop/reviews.py reviews + analytics
18
+
19
+Importing this package builds the `app` (connects the DB and registers all
20
+routes). Run with: python -m shop --port 8080
21
+"""
22
+from .config import app, db
23
+
24
+__all__ = ["app", "db"]
0
25
▼demo_shop/shop/__main__.py
+4-0
1
+from .cli import main
2
+
3
+if __name__ == "__main__":
4
+ main()
0
5
▼demo_shop/shop/cart.py
+143-0
1
+"""shop.cart — shopping cart lifecycle and checkout (cart -> order + payment)."""
2
+from __future__ import annotations
3
+
4
+from justapi import HTTPException
5
+
6
+from .config import app, db
7
+from .db import q, q1, new_token
8
+from .schemas import CartItemIn, CheckoutIn, validate
9
+
10
+
11
+@app.post("/carts", status_code=201)
12
+def create_cart(request):
13
+ body = request.json() or {}
14
+ customer_id = body.get("customer_id")
15
+ if customer_id:
16
+ if not q1("SELECT id FROM customers WHERE id = ?", [customer_id]):
17
+ raise HTTPException(status_code=404, detail="customer not found")
18
+ cart_id = new_token("cart")
19
+ db().execute("INSERT INTO carts (id, customer_id) VALUES (?,?)", [cart_id, customer_id])
20
+ return _cart_view(cart_id)
21
+
22
+
23
+@app.get("/carts/{cart_id}")
24
+def get_cart(request, cart_id: str):
25
+ return _cart_view(cart_id)
26
+
27
+
28
+@app.post("/carts/{cart_id}/items", body_schema=CartItemIn)
29
+def add_cart_item(request, cart_id: str):
30
+ d = validate(CartItemIn, request.json())
31
+ if not q1("SELECT id FROM carts WHERE id = ?", [cart_id]):
32
+ raise HTTPException(status_code=404, detail="cart not found")
33
+ prod = q1("SELECT id, stock, active FROM products WHERE id = ?", [d.product_id])
34
+ if not prod:
35
+ raise HTTPException(status_code=404, detail="product not found")
36
+ if not prod["active"]:
37
+ raise HTTPException(status_code=409, detail="product not available")
38
+ if d.quantity <= 0:
39
+ raise HTTPException(status_code=422, detail="quantity must be > 0")
40
+ if d.quantity > prod["stock"]:
41
+ raise HTTPException(status_code=409, detail=f"only {prod['stock']} in stock")
42
+ db().execute(
43
+ "INSERT INTO cart_items (cart_id, product_id, quantity) VALUES (?,?,?) "
44
+ "ON CONFLICT(cart_id, product_id) DO UPDATE SET quantity=excluded.quantity",
45
+ [cart_id, d.product_id, d.quantity],
46
+ )
47
+ db().execute("UPDATE carts SET updated_at = datetime('now') WHERE id = ?", [cart_id])
48
+ return _cart_view(cart_id)
49
+
50
+
51
+@app.delete("/carts/{cart_id}/items/{product_id}", status_code=204)
52
+def remove_cart_item(request, cart_id: str, product_id: str):
53
+ res = db().execute(
54
+ "DELETE FROM cart_items WHERE cart_id = ? AND product_id = ?",
55
+ [cart_id, product_id],
56
+ )
57
+ if int(res) == 0:
58
+ raise HTTPException(status_code=404, detail="item not in cart")
59
+ return None
60
+
61
+
62
+def _cart_view(cart_id: str) -> dict:
63
+ cart = q1("SELECT * FROM carts WHERE id = ?", [cart_id])
64
+ if not cart:
65
+ raise HTTPException(status_code=404, detail="cart not found")
66
+ items = q(
67
+ "SELECT ci.product_id, p.name_pt, p.price, ci.quantity, "
68
+ "(p.price * ci.quantity) AS line_total "
69
+ "FROM cart_items ci JOIN products p ON p.id = ci.product_id "
70
+ "WHERE ci.cart_id = ? ORDER BY ci.product_id",
71
+ [cart_id],
72
+ )
73
+ total = sum((it["line_total"] or 0) for it in items)
74
+ return {
75
+ "id": cart["id"],
76
+ "customer_id": cart["customer_id"],
77
+ "updated_at": cart["updated_at"],
78
+ "items": items,
79
+ "item_count": len(items),
80
+ "total": round(total, 2),
81
+ }
82
+
83
+
84
+@app.post("/carts/{cart_id}/checkout", body_schema=CheckoutIn)
85
+def checkout(request, cart_id: str):
86
+ d = validate(CheckoutIn, request.json())
87
+ cart = q1("SELECT * FROM carts WHERE id = ?", [cart_id])
88
+ if not cart:
89
+ raise HTTPException(status_code=404, detail="cart not found")
90
+ items = q(
91
+ "SELECT ci.product_id, p.name_pt, p.price, p.stock, ci.quantity "
92
+ "FROM cart_items ci JOIN products p ON p.id = ci.product_id "
93
+ "WHERE ci.cart_id = ?",
94
+ [cart_id],
95
+ )
96
+ if not items:
97
+ raise HTTPException(status_code=422, detail="cart is empty")
98
+ # Validate stock for every line before touching anything.
99
+ for it in items:
100
+ if it["quantity"] > it["stock"]:
101
+ raise HTTPException(
102
+ status_code=409,
103
+ detail=f"product {it['product_id']} only has {it['stock']} in stock",
104
+ )
105
+
106
+ customer_id = cart["customer_id"]
107
+ if not customer_id:
108
+ raise HTTPException(status_code=422, detail="cart has no customer_id; set one first")
109
+
110
+ order_id = new_token("order")
111
+ total = round(sum((it["price"] * it["quantity"]) for it in items), 2)
112
+
113
+ # One atomic transaction: create order, items, payment, decrement stock.
114
+ stmts = [
115
+ ("INSERT INTO orders (id, customer_id, status, total_amount) VALUES (?,?,?,?)",
116
+ [order_id, customer_id, "paid", total]),
117
+ ("INSERT INTO payments (order_id, method, installments, amount) VALUES (?,?,?,?)",
118
+ [order_id, d.method, d.installments, total]),
119
+ ]
120
+ for it in items:
121
+ stmts.append((
122
+ "INSERT INTO order_items (order_id, product_id, quantity, unit_price, freight) "
123
+ "VALUES (?,?,?,?,?)",
124
+ [order_id, it["product_id"], it["quantity"], it["price"], 0.0],
125
+ ))
126
+ stmts.append((
127
+ "UPDATE products SET stock = stock - ? WHERE id = ?",
128
+ [it["quantity"], it["product_id"]],
129
+ ))
130
+ stmts.append(("UPDATE orders SET paid_at = datetime('now') WHERE id = ?", [order_id]))
131
+ # Empty the cart after a successful purchase.
132
+ stmts.append(("DELETE FROM cart_items WHERE cart_id = ?", [cart_id]))
133
+
134
+ try:
135
+ db().transaction(stmts)
136
+ except Exception as e:
137
+ raise HTTPException(status_code=500, detail=f"checkout failed: {e}")
138
+
139
+ return q1(
140
+ "SELECT id, customer_id, status AS order_status, total_amount, created_at, paid_at "
141
+ "FROM orders WHERE id = ?",
142
+ [order_id],
143
+ )
0
144
▼demo_shop/shop/catalog.py
+192-0
1
+"""shop.catalog — categories, products (CRUD), and seller read endpoints."""
2
+from __future__ import annotations
3
+
4
+from typing import Optional
5
+
6
+from justapi import HTTPException
7
+
8
+from .config import app, db
9
+from .db import q, q1, paginate
10
+from .schemas import CategoryIn, ProductIn, ProductPatch, validate, slugify
11
+
12
+
13
+@app.get("/shop/status")
14
+def shop_health(request):
15
+ # NOTE: the framework treats a response dict containing a "status" key as a
16
+ # status wrapper and emits an empty body — avoid that key in JSON bodies.
17
+ n = q1("SELECT COUNT(*) AS c FROM products")
18
+ return {"health": "ok", "db": "sqlite", "products": n["c"]}
19
+
20
+
21
+@app.get("/categories")
22
+def list_categories(request):
23
+ rows = q(
24
+ "SELECT c.id, c.slug, c.name_pt, c.name_en, "
25
+ "(SELECT COUNT(*) FROM products p WHERE p.category_id = c.id) AS product_count "
26
+ "FROM categories c ORDER BY product_count DESC"
27
+ )
28
+ return {"items": rows}
29
+
30
+
31
+@app.post("/categories", status_code=201, body_schema=CategoryIn)
32
+def create_category(request):
33
+ d = validate(CategoryIn, request.json())
34
+ slug = d.slug or slugify(d.name_pt)
35
+ try:
36
+ db().execute(
37
+ "INSERT INTO categories (slug, name_pt, name_en) VALUES (?,?,?)",
38
+ [slug, d.name_pt, d.name_en],
39
+ )
40
+ except Exception as e:
41
+ if "UNIQUE" in str(e):
42
+ raise HTTPException(status_code=409, detail=f"category slug '{slug}' already exists")
43
+ raise HTTPException(status_code=400, detail=str(e))
44
+ return q1("SELECT * FROM categories WHERE slug = ?", [slug])
45
+
46
+
47
+@app.get("/products")
48
+def list_products(request):
49
+ qp = request.query_params
50
+ cat = qp.get("category") # category slug
51
+ q = qp.get("q")
52
+ min_price = qp.get("min_price")
53
+ max_price = qp.get("max_price")
54
+ sort = qp.get("sort", "id") # id | price | stock | name
55
+ order = qp.get("order", "asc")
56
+
57
+ wheres, params = [], []
58
+ if cat:
59
+ wheres.append("c.slug = ?")
60
+ params.append(cat)
61
+ if q:
62
+ wheres.append("(p.name_pt LIKE ? OR p.id LIKE ?)")
63
+ params += [f"%{q}%", f"%{q}%"]
64
+ if min_price is not None:
65
+ wheres.append("p.price >= ?")
66
+ params.append(float(min_price))
67
+ if max_price is not None:
68
+ wheres.append("p.price <= ?")
69
+ params.append(float(max_price))
70
+
71
+ where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
72
+ allowed = {"id": "p.id", "price": "p.price", "stock": "p.stock", "name": "p.name_pt"}
73
+ sort_col = allowed.get(sort, "p.id")
74
+ order_dir = "DESC" if order == "desc" else "ASC"
75
+
76
+ base = (
77
+ "SELECT p.id, p.name_pt, p.price, p.stock, p.active, "
78
+ "c.id AS category_id, c.name_en AS category "
79
+ f"FROM products p LEFT JOIN categories c ON c.id = p.category_id{where} "
80
+ f"ORDER BY {sort_col} {order_dir}"
81
+ )
82
+ count = f"SELECT COUNT(*) AS c FROM products p LEFT JOIN categories c ON c.id = p.category_id{where}"
83
+ return paginate(base, count, params, qp)
84
+
85
+
86
+@app.get("/products/{product_id}")
87
+def product_detail(request, product_id: str):
88
+ row = q1(
89
+ "SELECT p.*, c.name_en AS category FROM products p "
90
+ "LEFT JOIN categories c ON c.id = p.category_id WHERE p.id = ?",
91
+ [product_id],
92
+ )
93
+ if not row:
94
+ raise HTTPException(status_code=404, detail="product not found")
95
+ return row
96
+
97
+
98
+@app.post("/products", status_code=201, body_schema=ProductIn)
99
+def create_product(request):
100
+ d = validate(ProductIn, request.json())
101
+ try:
102
+ db().execute(
103
+ "INSERT INTO products (id, category_id, name_pt, weight_g, length_cm, "
104
+ "height_cm, width_cm, price, stock) VALUES (?,?,?,?,?,?,?,?,?)",
105
+ [d.id, d.category_id, d.name_pt, d.weight_g,
106
+ d.length_cm, d.height_cm, d.width_cm, d.price, d.stock],
107
+ )
108
+ except Exception as e:
109
+ if "UNIQUE" in str(e):
110
+ raise HTTPException(status_code=409, detail=f"product '{d.id}' exists")
111
+ raise HTTPException(status_code=400, detail=str(e))
112
+ return q1("SELECT * FROM products WHERE id = ?", [d.id])
113
+
114
+
115
+@app.patch("/products/{product_id}", body_schema=ProductPatch)
116
+def update_product(request, product_id: str):
117
+ cur = q1("SELECT id FROM products WHERE id = ?", [product_id])
118
+ if not cur:
119
+ raise HTTPException(status_code=404, detail="product not found")
120
+ d = validate(ProductPatch, request.json())
121
+ fields, params = [], []
122
+ if d.price is not None:
123
+ fields.append("price = ?"); params.append(d.price)
124
+ if d.stock is not None:
125
+ fields.append("stock = ?"); params.append(d.stock)
126
+ if d.active is not None:
127
+ fields.append("active = ?"); params.append(d.active)
128
+ if not fields:
129
+ return cur
130
+ db().execute(f"UPDATE products SET {', '.join(fields)} WHERE id = ?", params + [product_id])
131
+ return q1("SELECT * FROM products WHERE id = ?", [product_id])
132
+
133
+
134
+@app.delete("/products/{product_id}", status_code=204)
135
+def delete_product(request, product_id: str):
136
+ res = db().execute("DELETE FROM products WHERE id = ?", [product_id])
137
+ if int(res) == 0:
138
+ raise HTTPException(status_code=404, detail="product not found")
139
+ return None
140
+
141
+
142
+@app.get("/sellers")
143
+def list_sellers(request):
144
+ qp = request.query_params
145
+ q = qp.get("q")
146
+ state = qp.get("state")
147
+ sort = qp.get("sort", "gmv") # gmv | units | orders
148
+ order = qp.get("order", "desc")
149
+
150
+ wheres, params = [], []
151
+ if q:
152
+ wheres.append("s.id LIKE ?")
153
+ params.append(f"%{q}%")
154
+ if state:
155
+ wheres.append("s.state = ?")
156
+ params.append(state)
157
+ where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
158
+
159
+ allowed = {
160
+ "gmv": "COALESCE(SUM(oi.unit_price*oi.quantity),0)",
161
+ "units": "COUNT(oi.product_id)",
162
+ "orders": "COUNT(DISTINCT oi.order_id)",
163
+ }
164
+ sort_col = allowed.get(sort, allowed["gmv"])
165
+ order_dir = "ASC" if order == "asc" else "DESC"
166
+
167
+ base = (
168
+ "SELECT s.id, s.city, s.state, "
169
+ "COUNT(DISTINCT oi.order_id) AS orders, "
170
+ "COALESCE(SUM(oi.unit_price*oi.quantity),0) AS gmv, "
171
+ "COUNT(oi.product_id) AS units "
172
+ f"FROM sellers s LEFT JOIN order_items oi ON oi.seller_id = s.id{where} "
173
+ "GROUP BY s.id, s.city, s.state "
174
+ f"ORDER BY {sort_col} {order_dir}"
175
+ )
176
+ count = "SELECT COUNT(*) AS c FROM sellers s" + where
177
+ return paginate(base, count, params, qp)
178
+
179
+
180
+@app.get("/sellers/{seller_id}")
181
+def seller_detail(request, seller_id: str):
182
+ row = q1("SELECT * FROM sellers WHERE id = ?", [seller_id])
183
+ if not row:
184
+ raise HTTPException(status_code=404, detail="seller not found")
185
+ row["stats"] = q1(
186
+ "SELECT COUNT(DISTINCT oi.order_id) AS orders, "
187
+ "COALESCE(SUM(oi.unit_price*oi.quantity),0) AS gmv, "
188
+ "COUNT(oi.product_id) AS units "
189
+ "FROM order_items oi WHERE oi.seller_id = ?",
190
+ [seller_id],
191
+ )
192
+ return row
0
193
▼demo_shop/shop/cli.py
+22-0
1
+"""shop.app — CLI entrypoint for the MVP shop.
2
+
3
+Run: python -m shop --port 8080
4
+(or) python shop/app.py --port 8080
5
+"""
6
+from __future__ import annotations
7
+
8
+import argparse
9
+
10
+from . import app
11
+
12
+
13
+def main():
14
+ parser = argparse.ArgumentParser(description="demo_shop MVP e-commerce API")
15
+ parser.add_argument("--host", default="127.0.0.1")
16
+ parser.add_argument("--port", type=int, default=8080)
17
+ args = parser.parse_args()
18
+ app.run(f"{args.host}:{args.port}")
19
+
20
+
21
+if __name__ == "__main__":
22
+ main()
0
23
▼demo_shop/shop/config.py
+48-0
1
+"""shop.config — application instance, database wiring, and shared state.
2
+
3
+The JustAPI Runtime binds handlers to a single `JustAPIApp` instance and the
4
+DB pool is reached through `app.db`. We keep both here as package-level singletons
5
+so handler modules can import them without a circular dependency on the app
6
+module that registers routes.
7
+"""
8
+from __future__ import annotations
9
+
10
+import os
11
+
12
+from justapi import JustAPIApp
13
+
14
+HERE = os.path.dirname(os.path.abspath(__file__))
15
+SHOP_DB = os.environ.get("DEMO_SHOP_DB", os.path.join(HERE, "..", "shop.db"))
16
+
17
+
18
+def create_app() -> JustAPIApp:
19
+ """Build the JustAPIApp, wire the owned SQLite database, and return it.
20
+
21
+ Route handlers are registered by importing the handler modules (their
22
+ decorators run at import time against this same `app` instance).
23
+ """
24
+ application = JustAPIApp()
25
+ # Owned, writable database (seeded from Olist by db_setup.py). The framework's
26
+ # wal=True path is exercised separately (ADR-068); plain journal here so the
27
+ # demo runs anywhere without that flag.
28
+ application.set_database(f"sqlite://{SHOP_DB}")
29
+ application.connect_database()
30
+ return application
31
+
32
+
33
+# Single application instance for the process.
34
+app = create_app()
35
+
36
+
37
+def db():
38
+ """Return the live connection-pool handle (justapi Database)."""
39
+ return app.db
40
+
41
+
42
+# Import handler modules *after* `app` and `db` exist so their decorators
43
+# register on the app and their `from .config import app, db` resolves.
44
+from . import catalog # noqa: E402,F401
45
+from . import customers # noqa: E402,F401
46
+from . import cart # noqa: E402,F401
47
+from . import orders # noqa: E402,F401
48
+from . import reviews # noqa: E402,F401
0
49
▼demo_shop/shop/customers.py
+63-0
1
+"""shop.customers — customer registration and lookup."""
2
+from __future__ import annotations
3
+
4
+from justapi import HTTPException
5
+
6
+from .config import app, db
7
+from .db import q, q1, paginate
8
+from .schemas import CustomerIn, validate
9
+
10
+
11
+@app.get("/customers")
12
+def list_customers(request):
13
+ qp = request.query_params
14
+ q = qp.get("q")
15
+ state = qp.get("state")
16
+ sort = qp.get("sort", "id") # id | city | state
17
+ order = qp.get("order", "asc")
18
+
19
+ wheres, params = [], []
20
+ if q:
21
+ wheres.append("(c.id LIKE ? OR c.email LIKE ? OR c.city LIKE ?)")
22
+ params += [f"%{q}%", f"%{q}%", f"%{q}%"]
23
+ if state:
24
+ wheres.append("c.state = ?")
25
+ params.append(state)
26
+ where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
27
+
28
+ allowed = {"id": "c.id", "city": "c.city", "state": "c.state"}
29
+ sort_col = allowed.get(sort, "c.id")
30
+ order_dir = "DESC" if order == "desc" else "ASC"
31
+
32
+ base = (
33
+ "SELECT c.id, c.unique_id, c.city, c.state, c.email, "
34
+ "(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS orders "
35
+ f"FROM customers c{where} ORDER BY {sort_col} {order_dir}"
36
+ )
37
+ count = f"SELECT COUNT(*) AS c FROM customers c{where}"
38
+ return paginate(base, count, params, qp)
39
+
40
+
41
+@app.get("/customers/{customer_id}")
42
+def customer_detail(request, customer_id: str):
43
+ row = q1("SELECT * FROM customers WHERE id = ?", [customer_id])
44
+ if not row:
45
+ raise HTTPException(status_code=404, detail="customer not found")
46
+ row["orders"] = q(
47
+ "SELECT id, status AS order_status, total_amount, created_at FROM orders "
48
+ "WHERE customer_id = ? ORDER BY created_at DESC",
49
+ [customer_id],
50
+ )
51
+ return row
52
+
53
+
54
+@app.post("/customers", status_code=201, body_schema=CustomerIn)
55
+def create_customer(request):
56
+ d = validate(CustomerIn, request.json())
57
+ db().execute(
58
+ "INSERT INTO customers (id, unique_id, city, state, email) "
59
+ "VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET "
60
+ "city=excluded.city, state=excluded.state, email=excluded.email",
61
+ [d.id, d.unique_id, d.city, d.state, d.email],
62
+ )
63
+ return q1("SELECT * FROM customers WHERE id = ?", [d.id])
0
64
▼demo_shop/shop/db.py
+52-0
1
+"""shop.db — thin SQL helpers over the justapi Database pool.
2
+
3
+Wraps `app.db.query`/`execute` and adds pagination + a token generator. All
4
+queries use bound parameters (injection-safe).
5
+"""
6
+from __future__ import annotations
7
+
8
+import secrets
9
+from typing import Any, Optional
10
+
11
+from .config import db
12
+
13
+
14
+def q(sql: str, params: list | None = None) -> list[dict]:
15
+ rows = db().query(sql, params or [])
16
+ return [dict(r) for r in rows]
17
+
18
+
19
+def q1(sql: str, params: list | None = None) -> Optional[dict]:
20
+ rows = q(sql, params)
21
+ return rows[0] if rows else None
22
+
23
+
24
+def _page(qp: dict) -> tuple[int, int]:
25
+ try:
26
+ page = max(1, int(qp.get("page", 1)))
27
+ except (TypeError, ValueError):
28
+ page = 1
29
+ try:
30
+ size = max(1, min(200, int(qp.get("size", 20))))
31
+ except (TypeError, ValueError):
32
+ size = 20
33
+ return page, size
34
+
35
+
36
+def paginate(base_sql: str, count_sql: str, params: list, qp: dict) -> dict:
37
+ page, size = _page(qp)
38
+ total = q1(count_sql, params)
39
+ total_n = int(total["c"]) if total and "c" in total else 0
40
+ offset = (page - 1) * size
41
+ rows = q(base_sql + f" LIMIT {size} OFFSET {offset}", params)
42
+ return {
43
+ "page": page,
44
+ "size": size,
45
+ "total": total_n,
46
+ "pages": (total_n + size - 1) // size if total_n else 0,
47
+ "items": rows,
48
+ }
49
+
50
+
51
+def new_token(prefix: str) -> str:
52
+ return f"{prefix}_{secrets.token_hex(12)}"
0
53
▼demo_shop/shop/orders.py
+84-0
1
+"""shop.orders — order listing, detail, and status lifecycle."""
2
+from __future__ import annotations
3
+
4
+from justapi import HTTPException
5
+
6
+from .config import app, db
7
+from .db import q, q1, paginate
8
+from .schemas import OrderStatusIn, validate
9
+
10
+STATUSES = {"pending", "paid", "shipped", "delivered", "cancelled"}
11
+
12
+
13
+@app.get("/orders")
14
+def list_orders(request):
15
+ qp = request.query_params
16
+ wheres, params = [], []
17
+ if qp.get("customer_id"):
18
+ wheres.append("customer_id = ?"); params.append(qp["customer_id"])
19
+ if qp.get("status"):
20
+ wheres.append("status = ?"); params.append(qp["status"])
21
+ where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
22
+ base = (
23
+ "SELECT id, customer_id, status AS order_status, total_amount, created_at, paid_at "
24
+ f"FROM orders{where} ORDER BY created_at DESC"
25
+ )
26
+ count = f"SELECT COUNT(*) AS c FROM orders{where}"
27
+ return paginate(base, count, params, qp)
28
+
29
+
30
+@app.get("/orders/{order_id}")
31
+def order_detail(request, order_id: str):
32
+ order = q1(
33
+ "SELECT id, customer_id, status AS order_status, total_amount, "
34
+ "shipping_amount, created_at, paid_at, shipped_at, delivered_at "
35
+ "FROM orders WHERE id = ?",
36
+ [order_id],
37
+ )
38
+ if not order:
39
+ raise HTTPException(status_code=404, detail="order not found")
40
+ order = dict(order)
41
+ order["items"] = q(
42
+ "SELECT oi.product_id, p.name_pt, oi.quantity, oi.unit_price, oi.freight, "
43
+ "oi.seller_id FROM order_items oi JOIN products p ON p.id = oi.product_id "
44
+ "WHERE oi.order_id = ? ORDER BY oi.product_id",
45
+ [order_id],
46
+ )
47
+ order["payments"] = q(
48
+ "SELECT id, method, installments, amount, created_at FROM payments "
49
+ "WHERE order_id = ? ORDER BY id",
50
+ [order_id],
51
+ )
52
+ return order
53
+
54
+
55
+@app.patch("/orders/{order_id}/status", body_schema=OrderStatusIn)
56
+def update_order_status(request, order_id: str):
57
+ d = validate(OrderStatusIn, request.json())
58
+ if d.status not in STATUSES:
59
+ raise HTTPException(
60
+ status_code=422,
61
+ detail=f"invalid status; must be one of {sorted(STATUSES)}",
62
+ )
63
+ order = q1("SELECT id, status AS order_status FROM orders WHERE id = ?", [order_id])
64
+ if not order:
65
+ raise HTTPException(status_code=404, detail="order not found")
66
+ if order["order_status"] == "cancelled":
67
+ raise HTTPException(status_code=409, detail="order already cancelled")
68
+ ts_field = {
69
+ "paid": "paid_at",
70
+ "shipped": "shipped_at",
71
+ "delivered": "delivered_at",
72
+ }.get(d.status)
73
+ if ts_field:
74
+ db().execute(
75
+ f"UPDATE orders SET status = ?, {ts_field} = datetime('now') WHERE id = ?",
76
+ [d.status, order_id],
77
+ )
78
+ else:
79
+ db().execute("UPDATE orders SET status = ? WHERE id = ?", [d.status, order_id])
80
+ return q1(
81
+ "SELECT id, status AS order_status, created_at, paid_at, shipped_at, delivered_at "
82
+ "FROM orders WHERE id = ?",
83
+ [order_id],
84
+ )
0
85
▼demo_shop/shop/reviews.py
+77-0
1
+"""shop.reviews — customer reviews and shop analytics."""
2
+from __future__ import annotations
3
+
4
+from justapi import HTTPException
5
+
6
+from .config import app, db
7
+from .db import q, q1
8
+from .schemas import ReviewIn, validate
9
+
10
+
11
+@app.post("/orders/{order_id}/reviews", status_code=201, body_schema=ReviewIn)
12
+def create_review(request, order_id: str):
13
+ d = validate(ReviewIn, request.json())
14
+ if d.score < 1 or d.score > 5:
15
+ raise HTTPException(status_code=422, detail="score must be 1..5")
16
+ order = q1("SELECT * FROM orders WHERE id = ?", [order_id])
17
+ if not order:
18
+ raise HTTPException(status_code=404, detail="order not found")
19
+ if not q1(
20
+ "SELECT 1 FROM order_items WHERE order_id = ? AND product_id = ?",
21
+ [order_id, d.product_id],
22
+ ):
23
+ raise HTTPException(status_code=409, detail="product was not part of this order")
24
+ db().execute(
25
+ "INSERT INTO reviews (order_id, product_id, customer_id, score, title, comment) "
26
+ "VALUES (?,?,?,?,?,?)",
27
+ [order_id, d.product_id, order["customer_id"], d.score, d.title, d.comment],
28
+ )
29
+ # NOTE: last_insert_rowid() is per-connection and unreliable across a pooled
30
+ # connection, so re-select the row we just wrote by its natural keys.
31
+ return q1(
32
+ "SELECT * FROM reviews WHERE order_id = ? AND product_id = ? "
33
+ "ORDER BY id DESC LIMIT 1",
34
+ [order_id, d.product_id],
35
+ )
36
+
37
+
38
+@app.get("/analytics/sales-by-category")
39
+def analytics_sales_by_category(request):
40
+ rows = q(
41
+ "SELECT c.name_en AS category, COUNT(oi.product_id) AS units, "
42
+ "COALESCE(SUM(oi.unit_price * oi.quantity),0) AS revenue "
43
+ "FROM order_items oi "
44
+ "JOIN products p ON p.id = oi.product_id "
45
+ "LEFT JOIN categories c ON c.id = p.category_id "
46
+ "GROUP BY c.id ORDER BY revenue DESC"
47
+ )
48
+ return {"items": rows}
49
+
50
+
51
+@app.get("/analytics/top-products")
52
+def analytics_top_products(request):
53
+ qp = request.query_params
54
+ try:
55
+ limit = max(1, min(100, int(qp.get("limit", 20))))
56
+ except (TypeError, ValueError):
57
+ limit = 20
58
+ rows = q(
59
+ "SELECT p.id, p.name_pt, COUNT(oi.order_id) AS times_ordered, "
60
+ "COALESCE(SUM(oi.quantity),0) AS units, "
61
+ "COALESCE(SUM(oi.unit_price*oi.quantity),0) AS revenue "
62
+ "FROM order_items oi JOIN products p ON p.id = oi.product_id "
63
+ "GROUP BY p.id ORDER BY revenue DESC LIMIT ?",
64
+ [limit],
65
+ )
66
+ return {"items": rows}
67
+
68
+
69
+@app.get("/analytics/review-summary")
70
+def analytics_review_summary(request):
71
+ return q1(
72
+ "SELECT COUNT(*) AS total_reviews, "
73
+ "COALESCE(AVG(score),0) AS avg_score, "
74
+ "SUM(CASE WHEN score >= 4 THEN 1 ELSE 0 END) AS positive, "
75
+ "SUM(CASE WHEN score <= 2 THEN 1 ELSE 0 END) AS negative "
76
+ "FROM reviews"
77
+ ) or {"total_reviews": 0, "avg_score": 0, "positive": 0, "negative": 0}
0
78
▼demo_shop/shop/schemas.py
+136-0
1
+"""shop.schemas — request validation schemas and a validation helper.
2
+
3
+Schemas drive OpenAPI docs via `body_schema=`. Because the framework currently
4
+delivers the validated `body_schema` payload to handlers as raw `bytes` (see
5
+ADR-069), handlers parse `request.json()` and validate through `validate()`,
6
+which uses the Schema's generated JSON Schema.
7
+"""
8
+from __future__ import annotations
9
+
10
+from typing import Any, Optional
11
+
12
+from justapi import HTTPException, Schema
13
+
14
+
15
+def slugify(value: str) -> str:
16
+ """ASCII slug: lowercase, spaces/punctuation -> '-', collapse repeats."""
17
+ out = []
18
+ prev_dash = False
19
+ for ch in value.lower():
20
+ if ch.isalnum():
21
+ out.append(ch)
22
+ prev_dash = False
23
+ elif not prev_dash:
24
+ out.append("-")
25
+ prev_dash = True
26
+ s = "".join(out).strip("-")
27
+ while "--" in s:
28
+ s = s.replace("--", "-")
29
+ return s or "category"
30
+
31
+
32
+class CategoryIn(Schema):
33
+ slug: Optional[str] = None
34
+ name_pt: str
35
+ name_en: Optional[str] = None
36
+
37
+
38
+class ProductIn(Schema):
39
+ id: str
40
+ category_id: Optional[int] = None
41
+ name_pt: Optional[str] = None
42
+ weight_g: Optional[float] = None
43
+ length_cm: Optional[float] = None
44
+ height_cm: Optional[float] = None
45
+ width_cm: Optional[float] = None
46
+ price: float
47
+ stock: int = 0
48
+
49
+
50
+class ProductPatch(Schema):
51
+ price: Optional[float] = None
52
+ stock: Optional[int] = None
53
+ active: Optional[int] = None
54
+
55
+
56
+class CustomerIn(Schema):
57
+ id: str
58
+ unique_id: Optional[str] = None
59
+ city: Optional[str] = None
60
+ state: Optional[str] = None
61
+ email: Optional[str] = None
62
+
63
+
64
+class CartItemIn(Schema):
65
+ product_id: str
66
+ quantity: int = 1
67
+
68
+
69
+class CheckoutIn(Schema):
70
+ method: str = "credit_card"
71
+ installments: int = 1
72
+
73
+
74
+class OrderStatusIn(Schema):
75
+ status: str # pending|paid|shipped|delivered|cancelled
76
+
77
+
78
+class ReviewIn(Schema):
79
+ product_id: str
80
+ score: int
81
+ title: Optional[str] = None
82
+ comment: Optional[str] = None
83
+
84
+
85
+class _NS:
86
+ """Namespace view over a validated dict (attribute + item access)."""
87
+
88
+ def __init__(self, data: dict):
89
+ self.__dict__.update(data)
90
+
91
+ def __getattr__(self, k):
92
+ # Optional fields that were absent from the payload default to None.
93
+ return None
94
+
95
+ def __getitem__(self, k):
96
+ return self.__dict__[k]
97
+
98
+
99
+def validate(schema_cls, data) -> _NS:
100
+ """Validate `data` (already-parsed dict) against a justapi.Schema subclass.
101
+
102
+ Returns a namespace object with coerced fields. Raises HTTPException(422)
103
+ with a descriptive detail on the first validation failure.
104
+ """
105
+ if not isinstance(data, dict):
106
+ raise HTTPException(status_code=422, detail="body must be a JSON object")
107
+ schema = schema_cls._build_schema()
108
+ required = set(schema.get("required", []))
109
+ props = schema.get("properties", {})
110
+ out = {}
111
+ for name, prop in props.items():
112
+ present = name in data and data[name] is not None
113
+ if not present:
114
+ if name in required:
115
+ raise HTTPException(status_code=422, detail=f"missing required field: {name}")
116
+ # Use the Schema class attribute as the field's default (e.g. 1 for
117
+ # installments); fall back to None only if no default is declared.
118
+ default = getattr(schema_cls, name, None)
119
+ out[name] = default if not callable(default) else None
120
+ continue
121
+ val = data[name]
122
+ jt = prop.get("type")
123
+ try:
124
+ if jt == "integer":
125
+ out[name] = int(val)
126
+ elif jt == "number":
127
+ out[name] = float(val)
128
+ elif jt == "boolean":
129
+ out[name] = bool(val)
130
+ else:
131
+ out[name] = val
132
+ except (ValueError, TypeError):
133
+ raise HTTPException(
134
+ status_code=422, detail=f"field {name} must be {jt}, got {type(val).__name__}"
135
+ )
136
+ return _NS(out)
0
137
▼demo_shop/test_app.py
+121-69
1
-"""Smoke test for the demo_shop e-commerce API.
1
+"""End-to-end test for the MVP shop using justapi's AsyncTestClient.
2
2
3
-Validates that every route registers and returns sane data against the real
4
-Olist SQLite dataset, using the framework's AsyncTestClient (which sets up the
5
-DB runtime). Run:
6
-
7
- .venv/bin/python -m pytest crates/justapi-py/python/justapi/../../../../../demo_shop/test_app.py -q
8
-or simply:
9
- .venv/bin/python demo_shop/test_app.py
3
+The AsyncTestClient drives the real HTTP + routing + body_schema validation
4
+stack (the same path `app.run()` uses), so this verifies the API exactly as a
5
+client would see it. Run: python test_app.py
10
6
"""
7
+from __future__ import annotations
8
+
11
9
import asyncio
10
+import json
12
11
import os
13
12
import sys
14
13
15
-HERE = os.path.dirname(os.path.abspath(__file__))
16
-sys.path.insert(0, os.path.dirname(HERE)) # repo root
14
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+
16
+import shop as a
17
+from db_setup import open_shop
18
+from justapi.testing import AsyncTestClient
19
+
20
+PASS, FAIL = 0, 0
21
+
17
22
18
-from justapi.testing import AsyncTestClient # noqa: E402
23
+def check(name, cond, detail=""):
24
+ global PASS, FAIL
25
+ if cond:
26
+ PASS += 1
27
+ print(f" PASS {name}")
28
+ else:
29
+ FAIL += 1
30
+ print(f" FAIL {name} {detail}")
19
31
20
-import demo_shop.app as shop # noqa: E402
21
32
22
-DB_URL = f"sqlite://{shop.DB_PATH}"
33
+def reset_mutations():
34
+ con = open_shop()
35
+ for t in ("reviews", "payments", "order_items", "orders",
36
+ "cart_items", "carts"):
37
+ con.execute(f"DELETE FROM {t}")
38
+ con.execute("DELETE FROM products WHERE id LIKE 'test_%'")
39
+ con.execute("DELETE FROM categories WHERE slug LIKE 'test-%'")
40
+ con.execute("DELETE FROM customers WHERE id LIKE 'test_%'")
41
+ con.commit()
42
+ con.close()
23
43
24
44
25
45
async def main():
26
- async with AsyncTestClient(shop.app, database=DB_URL) as c:
27
- checks = []
28
-
29
- def chk(name, resp, expect_status=200):
30
- ok = resp.get("status") == expect_status
31
- checks.append((name, ok, resp.get("status")))
32
- return resp
33
-
34
- chk("shop/health", await c.get("/shop/health"))
35
- chk("shop/meta/tables", await c.get("/shop/meta/tables"))
36
- chk("products", await c.get("/products?size=3"))
37
- chk("products filter", await c.get("/products?category=bed_bath_table&size=2"))
38
- chk("products search", await c.get("/products?q=bed&size=2"))
39
- chk("categories", await c.get("/categories"))
40
- chk("sellers", await c.get("/sellers?size=3"))
41
- chk("reviews", await c.get("/reviews?size=3"))
42
- chk("reviews/summary", await c.get("/reviews/summary"))
43
- chk("geo/states", await c.get("/geolocation/states"))
44
- chk("analytics/sales-by-category", await c.get("/analytics/sales-by-category"))
45
- chk("analytics/top-sellers", await c.get("/analytics/top-sellers?limit=5"))
46
- chk("analytics/delivery", await c.get("/analytics/delivery-performance"))
47
- chk("analytics/monthly", await c.get("/analytics/monthly-revenue"))
48
- chk("search", await c.get("/search?q=sao"))
49
-
50
- # Find a real product_id + order_id + seller_id to exercise detail routes.
51
- prods = (await c.get("/products?size=1")).get("body")
52
- import json as _json
53
- prods = _json.loads(prods) if isinstance(prods, (bytes, str)) else prods
54
- pid = prods["items"][0]["product_id"]
55
- chk("product detail", await c.get(f"/products/{pid}"))
56
- chk("product reviews", await c.get(f"/products/{pid}/reviews?size=2"))
57
-
58
- sell = _json.loads((await c.get("/sellers?size=1")).get("body"))
59
- sid = sell["items"][0]["seller_id"]
60
- chk("seller detail", await c.get(f"/sellers/{sid}"))
61
- chk("seller products", await c.get(f"/sellers/{sid}/products?size=2"))
62
-
63
- # order id from analytics top-sellers join path: pull one from orders list
64
- ords = _json.loads((await c.get("/orders?size=1")).get("body"))
65
- oid = ords["items"][0]["order_id"]
66
- chk("order detail", await c.get(f"/orders/{oid}"))
67
- chk("order timeline", await c.get(f"/orders/{oid}/timeline"))
68
-
69
- # 404 behavior
70
- chk("product 404", await c.get("/products/does-not-exist"), expect_status=404)
71
- chk("search missing q", await c.get("/search"), expect_status=400)
72
-
73
- print("\n=== SMOKE RESULTS ===")
74
- failed = 0
75
- for name, ok, status in checks:
76
- print(f" {'PASS' if ok else 'FAIL'} {name} (status {status})")
77
- if not ok:
78
- failed += 1
79
- print(f"\n{failed} failed of {len(checks)}")
80
- return 1 if failed else 0
46
+ reset_mutations() # start from a clean slate (idempotent)
47
+ async with AsyncTestClient(a.app) as c:
48
+ J = lambda v: json.dumps(v).encode()
49
+ def PJ(r):
50
+ return json.loads(r["body"]) if r.get("body") else {}
51
+
52
+ print("=== catalog / read ===")
53
+ r = await c.get("/shop/status")
54
+ check("health ok", r.get("status") == 200, r)
55
+ r = await c.get("/categories")
56
+ check("categories listed", r.get("status") == 200 and PJ(r)["items"], r)
57
+ r = await c.get("/products?size=3&sort=price&order=desc")
58
+ body = PJ(r)
59
+ check("products paginated", body["total"] == 32951 and len(body["items"]) == 3, body.get("total"))
60
+ check("products sorted desc", body["items"][0]["price"] >= body["items"][1]["price"], body["items"][:2])
61
+ pid0 = body["items"][0]["id"]
62
+ r = await c.get(f"/products/{pid0}")
63
+ check("product detail", r.get("status") == 200 and PJ(r)["id"] == pid0, r)
64
+ r = await c.get("/products/does_not_exist")
65
+ check("product 404", r.get("status") == 404, r)
66
+
67
+ print("=== category create (CRUD) ===")
68
+ r = await c.post("/categories", J({"slug": "test-cat", "name_pt": "Teste", "name_en": "Test"}))
69
+ check("category created", r.get("status") in (200, 201), r)
70
+ r = await c.post("/categories", J({"slug": "test-cat", "name_pt": "X"}))
71
+ check("category dup 409", r.get("status") == 409, r)
72
+
73
+ print("=== product create / patch / delete (CRUD) ===")
74
+ r = await c.post("/products", J({"id": "test_prod_1", "category_id": 1, "name_pt": "Widget", "price": 12.5, "stock": 10}))
75
+ check("product created", r.get("status") in (200, 201) and PJ(r)["price"] == 12.5, r)
76
+ r = await c.patch("/products/test_prod_1", J({"price": 9.99, "stock": 5}))
77
+ check("product patched", PJ(r)["price"] == 9.99 and PJ(r)["stock"] == 5, r)
78
+ r = await c.delete("/products/test_prod_1")
79
+ check("product deleted 204", r.get("status") in (200, 204), r)
80
+ r = await c.get("/products/test_prod_1")
81
+ check("product gone 404", r.get("status") == 404, r)
82
+ await c.post("/products", J({"id": "test_prod_1", "category_id": 1, "name_pt": "Widget", "price": 12.5, "stock": 10}))
83
+
84
+ print("=== customer create ===")
85
+ r = await c.post("/customers", J({"id": "test_cust_1", "city": "Sao Paulo", "state": "SP", "email": "[email protected]"}))
86
+ check("customer created", r.get("status") in (200, 201) and PJ(r)["id"] == "test_cust_1", r)
87
+
88
+ print("=== cart -> checkout -> order ===")
89
+ r = await c.post("/carts", J({"customer_id": "test_cust_1"}))
90
+ cart_id = PJ(r)["id"]
91
+ check("cart created", r.get("status") in (200, 201) and PJ(r)["total"] == 0.0, r)
92
+ r = await c.post(f"/carts/{cart_id}/items", J({"product_id": "test_prod_1", "quantity": 2}))
93
+ check("item added", r.get("status") == 200 and PJ(r)["total"] == 25.0, r)
94
+ r = await c.post(f"/carts/{cart_id}/checkout", J({"method": "credit_card", "installments": 1}))
95
+ check("order created + paid", r.get("status") == 200 and PJ(r)["order_status"] == "paid" and PJ(r)["total_amount"] == 25.0, r)
96
+ oid = PJ(r)["id"]
97
+ stock_now = PJ(await c.get("/products/test_prod_1"))["stock"]
98
+ check("stock decremented 10->8", stock_now == 8, stock_now)
99
+ cart_after = PJ(await c.get(f"/carts/{cart_id}"))
100
+ check("cart emptied", cart_after["item_count"] == 0, cart_after)
101
+ od = PJ(await c.get(f"/orders/{oid}"))
102
+ check("order has item", len(od["items"]) == 1, od.get("items"))
103
+ check("order has payment", len(od["payments"]) == 1 and od["payments"][0]["amount"] == 25.0, od.get("payments"))
104
+
105
+ print("=== order status transitions ===")
106
+ r = await c.patch(f"/orders/{oid}/status", J({"status": "shipped"}))
107
+ check("shipped", PJ(r)["order_status"] == "shipped", r)
108
+ r = await c.patch(f"/orders/{oid}/status", J({"status": "delivered"}))
109
+ check("delivered", PJ(r)["order_status"] == "delivered", r)
110
+ r = await c.patch(f"/orders/{oid}/status", J({"status": "nope"}))
111
+ check("invalid status 422", r.get("status") == 422, r)
112
+
113
+ print("=== reviews ===")
114
+ r = await c.post(f"/orders/{oid}/reviews", J({"product_id": "test_prod_1", "score": 5, "title": "Great", "comment": "loved it"}))
115
+ check("review created", r.get("status") in (200, 201) and PJ(r)["score"] == 5, r)
116
+ r = await c.post(f"/orders/{oid}/reviews", J({"product_id": "other", "score": 5}))
117
+ check("review rejects product-not-in-order 409", r.get("status") == 409, r)
118
+ r = await c.get("/analytics/review-summary")
119
+ check("review summary", PJ(r)["total_reviews"] == 1 and PJ(r)["avg_score"] == 5.0, r)
120
+ r = await c.get("/analytics/sales-by-category")
121
+ check("sales-by-category revenue", any(x["revenue"] > 0 for x in PJ(r)["items"]), PJ(r)["items"][:1])
122
+
123
+ print("=== validation ===")
124
+ r = await c.post("/products", J({"id": "x"})) # missing required price
125
+ check("create product missing price 422/400", r.get("status") in (400, 422), r)
126
+ empty = PJ(await c.post("/carts", J({"customer_id": "test_cust_1"})))["id"]
127
+ r = await c.post(f"/carts/{empty}/checkout", J({"method": "boleto"}))
128
+ check("checkout empty cart 422", r.get("status") == 422, r)
129
+
130
+ reset_mutations()
131
+ print(f"\nSMOKE RESULTS\n {PASS} passed, {FAIL} failed")
132
+ raise SystemExit(1 if FAIL else 0)
81
133
82
134
83
135
if __name__ == "__main__":
84
- raise SystemExit(asyncio.run(main()))
136
+ asyncio.run(main())
85
137
▼demo_shop/test_lookup.py
+101-0
1
+"""Lookup flow check for the MVP shop (in-process, via AsyncTestClient).
2
+
3
+Verifies the read/lookup surface: status, categories, products, sellers
4
+(list + detail), customers (list + detail). Run: python test_lookup.py
5
+"""
6
+from __future__ import annotations
7
+
8
+import asyncio
9
+import json
10
+import sys
11
+import os
12
+
13
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+
15
+import shop as a
16
+from justapi.testing import AsyncTestClient
17
+
18
+PASS, FAIL = 0, 0
19
+
20
+
21
+def check(name, cond, detail=""):
22
+ global PASS, FAIL
23
+ if cond:
24
+ PASS += 1
25
+ print(f" PASS {name}")
26
+ else:
27
+ FAIL += 1
28
+ print(f" FAIL {name} {detail}")
29
+
30
+
31
+def PJ(r):
32
+ return json.loads(r["body"]) if r.get("body") else {}
33
+
34
+
35
+async def main():
36
+ async with AsyncTestClient(a.app) as c:
37
+ print("=== status ===")
38
+ r = await c.get("/shop/status")
39
+ b = PJ(r)
40
+ check("health ok", r.get("status") == 200 and b.get("health") == "ok", r)
41
+
42
+ print("=== categories ===")
43
+ r = await c.get("/categories?limit=3")
44
+ b = PJ(r)
45
+ check("categories listed", r.get("status") == 200 and b.get("items"), r)
46
+ check("category has product_count", "product_count" in b["items"][0], b["items"][0])
47
+
48
+ print("=== products ===")
49
+ r = await c.get("/products?size=3&sort=price&order=desc")
50
+ b = PJ(r)
51
+ check("products paginated", b["total"] == 32951 and len(b["items"]) == 3, b.get("total"))
52
+ pid0 = b["items"][0]["id"]
53
+ r = await c.get(f"/products/{pid0}")
54
+ check("product detail", r.get("status") == 200 and PJ(r)["id"] == pid0, r)
55
+ r = await c.get("/products/does_not_exist")
56
+ check("product 404", r.get("status") == 404, r)
57
+
58
+ print("=== sellers (NEW list endpoint) ===")
59
+ r = await c.get("/sellers?size=2")
60
+ b = PJ(r)
61
+ check("sellers listed (no 500)", r.get("status") == 200, r)
62
+ check("sellers has total", isinstance(b.get("total"), int) and b["total"] > 0, b)
63
+ check("seller row has stats cols",
64
+ b["items"] and {"id", "orders", "gmv", "units"} <= set(b["items"][0]), b.get("items"))
65
+ r = await c.get("/sellers?sort=units&order=desc&size=1")
66
+ check("sellers sort=units ok", r.get("status") == 200, r)
67
+ r = await c.get("/sellers?state=SP&size=1")
68
+ b = PJ(r)
69
+ check("sellers filter by state", r.get("status") == 200 and (not b["items"] or b["items"][0]["state"] == "SP"), b)
70
+ sid = b["items"][0]["id"] if b.get("items") else None
71
+ if sid:
72
+ r = await c.get(f"/sellers/{sid}")
73
+ check("seller detail", r.get("status") == 200 and PJ(r)["id"] == sid, r)
74
+ r = await c.get("/sellers/nope_404")
75
+ check("seller 404", r.get("status") == 404, r)
76
+
77
+ print("=== customers (NEW list endpoint) ===")
78
+ r = await c.get("/customers?size=2")
79
+ b = PJ(r)
80
+ check("customers listed", r.get("status") == 200 and b.get("total", 0) > 0, r)
81
+ check("customer row has orders col",
82
+ b["items"] and "orders" in b["items"][0], b.get("items"))
83
+ r = await c.get("/customers?state=SP&size=1")
84
+ b = PJ(r)
85
+ check("customers filter by state",
86
+ r.get("status") == 200 and (not b["items"] or b["items"][0]["state"] == "SP"), b)
87
+ r = await c.get("/customers?state=ZZ")
88
+ check("customers bad state -> empty 200",
89
+ r.get("status") == 200 and PJ(r).get("total", 1) == 0, r)
90
+ cid = b["items"][0]["id"] if b.get("items") else None
91
+ if cid:
92
+ r = await c.get(f"/customers/{cid}")
93
+ check("customer detail", r.get("status") == 200 and PJ(r)["id"] == cid, r)
94
+ r = await c.get("/customers/nope_404")
95
+ check("customer 404", r.get("status") == 404, r)
96
+
97
+
98
+if __name__ == "__main__":
99
+ asyncio.run(main())
100
+ print(f"\nLOOKUP RESULTS\n {PASS} passed, {FAIL} failed")
101
+ sys.exit(1 if FAIL else 0)
0
102