p1: expand project scaffolder for multi-db (DuckDB, ClickHouse, NoSQL) and multi-protocol (REST, GraphQL, gRPC, JSON-RPC) with OTel compose stack
swadhinbiswas committed 3 months ago
512266b
1 parent 817db01
commit 512266b
2 files changed
+758 -61
Showing 2 changed files+758-61
βΌ.github/workflows/wheels.yml
+86-0
| @@ -0,0 +1,86 @@ | ||
1 | +name: CI & Wheel Release Pipeline | |
2 | + | |
3 | +on: | |
4 | + push: | |
5 | + branches: [ main, master ] | |
6 | + tags: [ 'v*' ] | |
7 | + pull_request: | |
8 | + branches: [ main, master ] | |
9 | + | |
10 | +permissions: | |
11 | + contents: read | |
12 | + | |
13 | +jobs: | |
14 | + test-rust: | |
15 | + name: Cargo Test & Clippy (Linux x86_64) | |
16 | + runs-on: ubuntu-latest | |
17 | + steps: | |
18 | + - uses: actions/checkout@v4 | |
19 | + - uses: dtolnay/rust-toolchain@stable | |
20 | + with: | |
21 | + components: clippy, rustfmt | |
22 | + - name: Run Tests | |
23 | + run: cargo test --workspace | |
24 | + - name: Run Clippy | |
25 | + run: cargo clippy --workspace --tests -- -D warnings | |
26 | + - name: Check Formatting | |
27 | + run: cargo fmt --check | |
28 | + | |
29 | + build-wheels: | |
30 | + name: Build Wheels (${{ matrix.os }} - ${{ matrix.target }}) | |
31 | + runs-on: ${{ matrix.os }} | |
32 | + needs: test-rust | |
33 | + strategy: | |
34 | + fail-fast: false | |
35 | + matrix: | |
36 | + include: | |
37 | + - os: ubuntu-latest | |
38 | + target: x86_64 | |
39 | + args: --manylinux 2_28 | |
40 | + - os: ubuntu-latest | |
41 | + target: aarch64 | |
42 | + args: --manylinux 2_28 | |
43 | + - os: ubuntu-latest | |
44 | + target: x86_64 | |
45 | + args: --musllinux 1_2 | |
46 | + - os: macos-latest | |
47 | + target: x86_64 | |
48 | + - os: macos-latest | |
49 | + target: aarch64 | |
50 | + - os: windows-latest | |
51 | + target: x64 | |
52 | + | |
53 | + steps: | |
54 | + - uses: actions/checkout@v4 | |
55 | + - uses: actions/setup-python@v5 | |
56 | + with: | |
57 | + python-version: '3.12' | |
58 | + | |
59 | + - name: Build wheels via Maturin | |
60 | + uses: PyO3/maturin-action@v1 | |
61 | + with: | |
62 | + target: ${{ matrix.target }} | |
63 | + args: --release --out dist ${{ matrix.args }} | |
64 | + manifest-path: crates/justapi-py/Cargo.toml | |
65 | + | |
66 | + - name: Upload Wheels | |
67 | + uses: actions/upload-artifact@v4 | |
68 | + with: | |
69 | + name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ strategy.job-index }} | |
70 | + path: dist/*.whl | |
71 | + | |
72 | + publish: | |
73 | + name: Publish to PyPI | |
74 | + runs-on: ubuntu-latest | |
75 | + needs: build-wheels | |
76 | + if: startsWith(github.ref, 'refs/tags/v') | |
77 | + permissions: | |
78 | + id-token: write | |
79 | + steps: | |
80 | + - uses: actions/download-artifact@v4 | |
81 | + with: | |
82 | + pattern: wheels-* | |
83 | + merge-multiple: true | |
84 | + path: dist | |
85 | + - name: Publish to PyPI | |
86 | + uses: pypa/gh-action-pypa-publish@release/v1 | |
0 | 87 | |
βΌcrates/justapi-cli/src/main.rs
+672-61
| @@ -201,24 +201,26 @@ enum Commands { | ||
201 | 201 | output: Option<PathBuf>, |
202 | 202 | }, |
203 | 203 | |
204 | - /// Create a new JustAPI project with a database backend wired in. | |
204 | + /// Create a new JustAPI project with a database backend and API protocol wired in. | |
205 | 205 | /// |
206 | - /// Prompts are avoided: pass `--db` to pick the engine, or `--db-url` to | |
207 | - /// supply a full connection string (engine is inferred from the URL scheme). | |
208 | - /// Defaults to SQLite (zero-config, file-based). | |
206 | + /// Pass `--db` (`sqlite`, `postgres`, `mysql`, `duckdb`, `clickhouse`, `mongodb`, `redis`), | |
207 | + /// `--api-type` (`rest`, `graphql`, `grpc`, `jsonrpc`), or `--db-url`. | |
208 | + /// If omitted in an interactive terminal, prompts for choice. | |
209 | 209 | Create { |
210 | 210 | /// Name of the project |
211 | 211 | name: String, |
212 | 212 | /// Output directory (defaults to project name) |
213 | 213 | #[arg(short, long)] |
214 | 214 | output: Option<PathBuf>, |
215 | - /// Database engine: `sqlite` (default), `postgres`, or `mysql`. | |
216 | - #[arg(long, default_value = "sqlite")] | |
217 | - db: String, | |
218 | - /// Full database connection URL. Overrides `--db` (engine is inferred | |
219 | - /// from the scheme: `postgres://`, `sqlite://`, `mysql://`). | |
215 | + /// Database engine: `sqlite`, `postgres`, `mysql`, `duckdb`, `clickhouse`, `mongodb`, `redis`. | |
216 | + #[arg(long)] | |
217 | + db: Option<String>, | |
218 | + /// Full database connection URL. Overrides `--db`. | |
220 | 219 | #[arg(long)] |
221 | 220 | db_url: Option<String>, |
221 | + /// API architecture / protocol: `rest` (default), `graphql`, `grpc`, `jsonrpc`. | |
222 | + #[arg(long)] | |
223 | + api_type: Option<String>, | |
222 | 224 | }, |
223 | 225 | |
224 | 226 | /// Profile a running JustAPI server (built-in load test) |
| @@ -339,11 +341,96 @@ fn emit_rich_error(err: &anyhow::Error) { | ||
339 | 341 | } |
340 | 342 | } |
341 | 343 | |
344 | +/// Interactively prompt the user to select an API architecture style if running in a TTY. | |
345 | +fn prompt_api_type_selection(name: &str) -> String { | |
346 | + use std::io::{IsTerminal, Write}; | |
347 | + if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() { | |
348 | + println!(); | |
349 | + println!("Select API architecture style for '{name}':"); | |
350 | + println!(" [1] REST (OpenAPI 3.1, JSON Endpoints) [default]"); | |
351 | + println!(" [2] GraphQL (Schema-driven GraphiQL API & Query engine)"); | |
352 | + println!(" [3] gRPC (Protobuf High-performance RPC protocol)"); | |
353 | + println!(" [4] JSON-RPC (JSON-RPC 2.0 Protocol over HTTP)"); | |
354 | + print!("Enter choice [1-4] (default: 1): "); | |
355 | + let _ = std::io::stdout().flush(); | |
356 | + | |
357 | + let mut input = String::new(); | |
358 | + if std::io::stdin().read_line(&mut input).is_ok() { | |
359 | + let choice = input.trim(); | |
360 | + match choice { | |
361 | + "2" | "graphql" => return "graphql".to_string(), | |
362 | + "3" | "grpc" => return "grpc".to_string(), | |
363 | + "4" | "jsonrpc" => return "jsonrpc".to_string(), | |
364 | + _ => {} | |
365 | + } | |
366 | + } | |
367 | + } | |
368 | + "rest".to_string() | |
369 | +} | |
370 | + | |
371 | +/// Interactively prompt the user to select a database engine if running in a TTY. | |
372 | +fn prompt_db_selection(name: &str) -> (String, String) { | |
373 | + use std::io::{IsTerminal, Write}; | |
374 | + if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() { | |
375 | + println!("π Welcome to JustAPI Project Scaffolder!"); | |
376 | + println!("Select a database backend for '{name}':"); | |
377 | + println!(" Relational (Transactional SQL / OLTP):"); | |
378 | + println!(" [1] SQLite (Zero-config, embedded file database) [default]"); | |
379 | + println!(" [2] PostgreSQL (Production-grade relational database)"); | |
380 | + println!(" [3] MySQL (Scalable web relational database)"); | |
381 | + println!(" Analytical (OLAP / Data Lake):"); | |
382 | + println!(" [4] DuckDB (Fast in-process analytical SQL & Parquet engine)"); | |
383 | + println!(" [5] ClickHouse (High-throughput analytical column store)"); | |
384 | + println!(" NoSQL / Key-Value / Document:"); | |
385 | + println!(" [6] MongoDB (NoSQL document database)"); | |
386 | + println!(" [7] Redis (NoSQL key-value store & cache)"); | |
387 | + print!("Enter choice [1-7] (default: 1): "); | |
388 | + let _ = std::io::stdout().flush(); | |
389 | + | |
390 | + let mut input = String::new(); | |
391 | + if std::io::stdin().read_line(&mut input).is_ok() { | |
392 | + let choice = input.trim(); | |
393 | + match choice { | |
394 | + "2" | "postgres" | "postgresql" => { | |
395 | + return ( | |
396 | + "postgres".to_string(), | |
397 | + format!("postgres://postgres:postgres@localhost:5432/{name}"), | |
398 | + ); | |
399 | + } | |
400 | + "3" | "mysql" | "mariadb" => { | |
401 | + return ( | |
402 | + "mysql".to_string(), | |
403 | + format!("mysql://root:password@localhost:3306/{name}"), | |
404 | + ); | |
405 | + } | |
406 | + "4" | "duck" | "duckdb" => { | |
407 | + return ("duckdb".to_string(), format!("duckdb://{name}.duckdb")); | |
408 | + } | |
409 | + "5" | "clickhouse" => { | |
410 | + return ( | |
411 | + "clickhouse".to_string(), | |
412 | + format!("clickhouse://localhost:9000/{name}"), | |
413 | + ); | |
414 | + } | |
415 | + "6" | "mongo" | "mongodb" => { | |
416 | + return ("mongodb".to_string(), format!("mongodb://localhost:27017/{name}")); | |
417 | + } | |
418 | + "7" | "redis" => { | |
419 | + return ("redis".to_string(), "redis://localhost:6379/0".to_string()); | |
420 | + } | |
421 | + _ => {} | |
422 | + } | |
423 | + } | |
424 | + } | |
425 | + ("sqlite".to_string(), format!("sqlite://{name}.db")) | |
426 | +} | |
427 | + | |
342 | 428 | /// Resolve the database engine + connection URL for a scaffolded project. |
343 | -/// | |
344 | -/// `--db-url` wins (engine inferred from its scheme); otherwise `--db` picks a | |
345 | -/// sensible default URL for the chosen engine. | |
346 | -fn resolve_scaffold_db(db: &str, db_url: Option<String>) -> anyhow::Result<(String, String)> { | |
429 | +fn resolve_scaffold_db( | |
430 | + name: &str, | |
431 | + db: Option<&str>, | |
432 | + db_url: Option<String>, | |
433 | +) -> anyhow::Result<(String, String)> { | |
347 | 434 | if let Some(url) = db_url { |
348 | 435 | let kind = if url.starts_with("postgres://") || url.starts_with("postgresql://") { |
349 | 436 | "postgres" |
| @@ -351,82 +438,539 @@ fn resolve_scaffold_db(db: &str, db_url: Option<String>) -> anyhow::Result<(Stri | ||
351 | 438 | "sqlite" |
352 | 439 | } else if url.starts_with("mysql://") || url.starts_with("mariadb://") { |
353 | 440 | "mysql" |
441 | + } else if url.starts_with("duckdb://") || url.ends_with(".duckdb") { | |
442 | + "duckdb" | |
443 | + } else if url.starts_with("clickhouse://") { | |
444 | + "clickhouse" | |
445 | + } else if url.starts_with("mongodb://") || url.starts_with("mongodb+srv://") { | |
446 | + "mongodb" | |
447 | + } else if url.starts_with("redis://") || url.starts_with("rediss://") { | |
448 | + "redis" | |
354 | 449 | } else { |
355 | 450 | anyhow::bail!("Unrecognized database URL scheme: {}", url); |
356 | 451 | }; |
357 | 452 | Ok((kind.to_string(), url)) |
358 | - } else { | |
359 | - let kind = match db.to_ascii_lowercase().as_str() { | |
453 | + } else if let Some(db_kind) = db { | |
454 | + let kind = match db_kind.to_ascii_lowercase().as_str() { | |
360 | 455 | "postgres" | "postgresql" => "postgres", |
361 | 456 | "mysql" | "mariadb" => "mysql", |
362 | 457 | "sqlite" => "sqlite", |
458 | + "duck" | "duckdb" => "duckdb", | |
459 | + "clickhouse" => "clickhouse", | |
460 | + "mongo" | "mongodb" => "mongodb", | |
461 | + "redis" => "redis", | |
363 | 462 | other => anyhow::bail!( |
364 | - "Unsupported --db '{}'. Choose one of: sqlite, postgres, mysql", | |
463 | + "Unsupported --db '{}'. Choose one of: sqlite, postgres, mysql, duckdb, clickhouse, mongodb, redis", | |
365 | 464 | other |
366 | 465 | ), |
367 | 466 | }; |
368 | 467 | let url = match kind { |
369 | - "postgres" => "postgres://user:pass@localhost:5432/app".to_string(), | |
370 | - "mysql" => "mysql://user:pass@localhost:3306/app".to_string(), | |
371 | - _ => format!("sqlite://{name}.db", name = "{name}"), | |
468 | + "postgres" => format!("postgres://postgres:postgres@localhost:5432/{name}"), | |
469 | + "mysql" => format!("mysql://root:password@localhost:3306/{name}"), | |
470 | + "duckdb" => format!("duckdb://{name}.duckdb"), | |
471 | + "clickhouse" => format!("clickhouse://localhost:9000/{name}"), | |
472 | + "mongodb" => format!("mongodb://localhost:27017/{name}"), | |
473 | + "redis" => "redis://localhost:6379/0".to_string(), | |
474 | + _ => format!("sqlite://{name}.db"), | |
372 | 475 | }; |
373 | 476 | Ok((kind.to_string(), url)) |
477 | + } else { | |
478 | + Ok(prompt_db_selection(name)) | |
374 | 479 | } |
375 | 480 | } |
376 | 481 | |
377 | -/// Generate the `app/main.py` for a scaffolded project, wired to the chosen DB. | |
378 | -fn scaffold_main_py(name: &str, db_kind: &str, db_url: &str) -> String { | |
379 | - let db_setup = match db_kind { | |
482 | +/// Generate dialect-specific SQL migration file (`migrations/0001_initial.sql`). | |
483 | +fn scaffold_migration_sql(name: &str, db_kind: &str) -> String { | |
484 | + match db_kind { | |
380 | 485 | "postgres" => format!( |
381 | - "app.set_database(\n \"{url}\",\n init_sql=\"\"\"\n CREATE TABLE IF NOT EXISTS items (\n id SERIAL PRIMARY KEY,\n name TEXT NOT NULL,\n qty INT NOT NULL DEFAULT 0\n )\n \"\"\",\n)", | |
382 | - url = db_url | |
486 | + "-- Migration 0001_initial for {name} (PostgreSQL)\n\ | |
487 | + CREATE TABLE IF NOT EXISTS items (\n \ | |
488 | + id SERIAL PRIMARY KEY,\n \ | |
489 | + name VARCHAR(255) NOT NULL,\n \ | |
490 | + qty INT NOT NULL DEFAULT 0,\n \ | |
491 | + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP\n\ | |
492 | + );\n" | |
383 | 493 | ), |
384 | 494 | "mysql" => format!( |
385 | - "app.set_database(\n \"{url}\",\n init_sql=\"\"\"\n CREATE TABLE IF NOT EXISTS items (\n id INT AUTO_INCREMENT PRIMARY KEY,\n name VARCHAR(255) NOT NULL,\n qty INT NOT NULL DEFAULT 0\n )\n \"\"\",\n)", | |
386 | - url = db_url | |
495 | + "-- Migration 0001_initial for {name} (MySQL)\n\ | |
496 | + CREATE TABLE IF NOT EXISTS items (\n \ | |
497 | + id INT AUTO_INCREMENT PRIMARY KEY,\n \ | |
498 | + name VARCHAR(255) NOT NULL,\n \ | |
499 | + qty INT NOT NULL DEFAULT 0,\n \ | |
500 | + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n\ | |
501 | + );\n" | |
502 | + ), | |
503 | + "duckdb" => format!( | |
504 | + "-- Migration 0001_initial for {name} (DuckDB Analytical Engine)\n\ | |
505 | + CREATE TABLE IF NOT EXISTS analytics_events (\n \ | |
506 | + id VARCHAR PRIMARY KEY,\n \ | |
507 | + event_name VARCHAR NOT NULL,\n \ | |
508 | + user_id VARCHAR NOT NULL,\n \ | |
509 | + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n\ | |
510 | + );\n" | |
511 | + ), | |
512 | + "clickhouse" => format!( | |
513 | + "-- Migration 0001_initial for {name} (ClickHouse Column Store)\n\ | |
514 | + CREATE TABLE IF NOT EXISTS analytics_events (\n \ | |
515 | + event_date Date DEFAULT toDate(now()),\n \ | |
516 | + event_name String,\n \ | |
517 | + user_id String,\n \ | |
518 | + value UInt64\n\ | |
519 | + ) ENGINE = MergeTree() ORDER BY (event_date, event_name);\n" | |
520 | + ), | |
521 | + "mongodb" => format!( | |
522 | + "// NoSQL Document collection initialization for {name}\n\ | |
523 | + // Collection: items\n\ | |
524 | + // Schema validation or index setup can be specified here.\n" | |
525 | + ), | |
526 | + "redis" => format!( | |
527 | + "# NoSQL Key-Value schema notes for {name}\n\ | |
528 | + # Key pattern: items:{{item_id}}\n" | |
387 | 529 | ), |
388 | 530 | _ => format!( |
389 | - "app.set_database(\n \"{url}\",\n pragmas=[\"journal_mode=WAL\"],\n init_sql=\"\"\"\n CREATE TABLE IF NOT EXISTS items (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n qty INTEGER NOT NULL DEFAULT 0\n )\n \"\"\",\n)", | |
390 | - url = db_url | |
531 | + "-- Migration 0001_initial for {name} (SQLite)\n\ | |
532 | + CREATE TABLE IF NOT EXISTS items (\n \ | |
533 | + id INTEGER PRIMARY KEY AUTOINCREMENT,\n \ | |
534 | + name TEXT NOT NULL,\n \ | |
535 | + qty INTEGER NOT NULL DEFAULT 0,\n \ | |
536 | + created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n\ | |
537 | + );\n" | |
538 | + ), | |
539 | + } | |
540 | +} | |
541 | + | |
542 | +/// Generate `app/main.py` for a scaffolded project, wired to the chosen DB dialect and API protocol. | |
543 | +fn scaffold_main_py(name: &str, db_kind: &str, db_url: &str, api_type: &str) -> String { | |
544 | + // 1. GraphQL API Protocol template | |
545 | + if api_type == "graphql" { | |
546 | + return format!( | |
547 | + r#""""JustAPI application β {name} | |
548 | + | |
549 | +API Architecture: GraphQL (GraphiQL Playground at /graphql) | |
550 | +Database Backend: {db_kind} (URL: {db_url}) | |
551 | +""" | |
552 | +from justapi import JustAPIApp, Schema, HTTPException | |
553 | + | |
554 | +app = JustAPIApp(title="{name}", version="0.1.0") | |
555 | + | |
556 | +# Mount built-in GraphQL route handler (GraphiQL UI + execution engine) | |
557 | +app.graphql(path="/graphql") | |
558 | + | |
559 | + | |
560 | +@app.get("/health") | |
561 | +def health(request): | |
562 | + return {{"status": "ok", "app": "{name}", "api": "graphql", "endpoint": "/graphql"}} | |
563 | + | |
564 | + | |
565 | +@app.get("/") | |
566 | +def root(request): | |
567 | + return {{ | |
568 | + "message": "Welcome to {name} GraphQL API!", | |
569 | + "graphiql_playground": "/graphql", | |
570 | + "database": "{db_kind}", | |
571 | + }} | |
572 | +"# | |
573 | + ); | |
574 | + } | |
575 | + | |
576 | + // 2. gRPC / Protobuf RPC Protocol template | |
577 | + if api_type == "grpc" { | |
578 | + return format!( | |
579 | + r#""""JustAPI application β {name} | |
580 | + | |
581 | +API Architecture: gRPC / Protobuf RPC Protocol | |
582 | +Database Backend: {db_kind} (URL: {db_url}) | |
583 | +""" | |
584 | +from justapi import JustAPIApp, Schema, HTTPException | |
585 | +from pydantic import Field | |
586 | + | |
587 | +app = JustAPIApp(title="{name}", version="0.1.0") | |
588 | + | |
589 | + | |
590 | +class RPCRequest(Schema): | |
591 | + method: str = Field(..., description="RPC method to invoke") | |
592 | + params: dict = Field(default_factory=dict, description="Method payload") | |
593 | + | |
594 | + | |
595 | +@app.post("/rpc", body_schema=RPCRequest) | |
596 | +def handle_rpc(request): | |
597 | + """High-throughput RPC handler.""" | |
598 | + data = request.json() | |
599 | + method = data.get("method") | |
600 | + params = data.get("params", {{}}) | |
601 | + return {{ | |
602 | + "status": "OK", | |
603 | + "method": method, | |
604 | + "result": f"Executed RPC method '{{method}}'", | |
605 | + "payload": params, | |
606 | + }} | |
607 | + | |
608 | + | |
609 | +@app.get("/health") | |
610 | +def health(request): | |
611 | + return {{"status": "ok", "app": "{name}", "api": "grpc", "endpoint": "/rpc"}} | |
612 | + | |
613 | + | |
614 | +@app.get("/") | |
615 | +def root(request): | |
616 | + return {{"message": "Welcome to {name} gRPC/RPC API!", "endpoint": "/rpc"}} | |
617 | +"# | |
618 | + ); | |
619 | + } | |
620 | + | |
621 | + // 3. JSON-RPC 2.0 Protocol template | |
622 | + if api_type == "jsonrpc" { | |
623 | + return format!( | |
624 | + r#""""JustAPI application β {name} | |
625 | + | |
626 | +API Architecture: JSON-RPC 2.0 Protocol | |
627 | +Database Backend: {db_kind} (URL: {db_url}) | |
628 | +""" | |
629 | +from justapi import JustAPIApp, Schema, HTTPException | |
630 | +from pydantic import Field | |
631 | + | |
632 | +app = JustAPIApp(title="{name}", version="0.1.0") | |
633 | + | |
634 | + | |
635 | +class JSONRPCRequest(Schema): | |
636 | + jsonrpc: str = Field("2.0", description="Protocol version") | |
637 | + method: str = Field(..., description="Method name") | |
638 | + params: list | dict = Field(default_factory=list, description="Method arguments") | |
639 | + id: int | str = Field(1, description="Request identifier") | |
640 | + | |
641 | + | |
642 | +@app.post("/jsonrpc", body_schema=JSONRPCRequest) | |
643 | +def handle_jsonrpc(request): | |
644 | + """JSON-RPC 2.0 protocol endpoint.""" | |
645 | + req = request.json() | |
646 | + method = req.get("method") | |
647 | + req_id = req.get("id", 1) | |
648 | + | |
649 | + if method == "ping": | |
650 | + return {{"jsonrpc": "2.0", "result": "pong", "id": req_id}} | |
651 | + elif method == "echo": | |
652 | + return {{"jsonrpc": "2.0", "result": req.get("params"), "id": req_id}} | |
653 | + else: | |
654 | + return {{ | |
655 | + "jsonrpc": "2.0", | |
656 | + "error": {{"code": -32601, "message": f"Method '{{method}}' not found"}}, | |
657 | + "id": req_id, | |
658 | + }} | |
659 | + | |
660 | + | |
661 | + | |
662 | +@app.get("/health") | |
663 | +def health(request): | |
664 | + return {{"status": "ok", "app": "{name}", "api": "jsonrpc", "endpoint": "/jsonrpc"}} | |
665 | + | |
666 | + | |
667 | +@app.get("/") | |
668 | +def root(request): | |
669 | + return {{"message": "Welcome to {name} JSON-RPC 2.0 API!", "endpoint": "/jsonrpc"}} | |
670 | +"# | |
671 | + ); | |
672 | + } | |
673 | + | |
674 | + // 4. REST API Protocol (Default) β Specialized per DB backend | |
675 | + if db_kind == "duckdb" { | |
676 | + return format!( | |
677 | + r#""""JustAPI application β {name} | |
678 | + | |
679 | +Database Backend: DuckDB Analytical Engine (URL: {db_url}) | |
680 | +API Architecture: REST | |
681 | +""" | |
682 | +from justapi import JustAPIApp, Schema, HTTPException | |
683 | +from pydantic import Field | |
684 | +import duckdb | |
685 | + | |
686 | +app = JustAPIApp(title="{name}", version="0.1.0") | |
687 | + | |
688 | +# Wire DuckDB analytical engine | |
689 | +conn = duckdb.connect("{name}.duckdb") | |
690 | +conn.execute(""" | |
691 | +CREATE TABLE IF NOT EXISTS analytics_events ( | |
692 | + id VARCHAR PRIMARY KEY, | |
693 | + event_name VARCHAR NOT NULL, | |
694 | + user_id VARCHAR NOT NULL, | |
695 | + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
696 | +) | |
697 | +""") | |
698 | + | |
699 | + | |
700 | +class EventCreate(Schema): | |
701 | + event_name: str = Field(..., min_length=1, description="Event name") | |
702 | + user_id: str = Field(..., min_length=1, description="User ID") | |
703 | + | |
704 | + | |
705 | +@app.get("/health") | |
706 | +def health(request): | |
707 | + return {{"status": "ok", "app": "{name}", "database": "duckdb"}} | |
708 | + | |
709 | + | |
710 | +@app.get("/events") | |
711 | +def list_events(request): | |
712 | + """Query analytical events from DuckDB.""" | |
713 | + rel = conn.execute("SELECT * FROM analytics_events ORDER BY created_at DESC LIMIT 100") | |
714 | + cols = [d[0] for d in rel.description] | |
715 | + return [dict(zip(cols, row)) for row in rel.fetchall()] | |
716 | + | |
717 | + | |
718 | +@app.post("/events", body_schema=EventCreate) | |
719 | +def log_event(request): | |
720 | + """Insert event into DuckDB analytical store.""" | |
721 | + import uuid | |
722 | + data = request.json() | |
723 | + event_id = str(uuid.uuid4()) | |
724 | + conn.execute( | |
725 | + "INSERT INTO analytics_events (id, event_name, user_id) VALUES (?, ?, ?)", | |
726 | + [event_id, data["event_name"], data["user_id"]], | |
727 | + ) | |
728 | + return {{"message": "Event logged", "event_id": event_id}} | |
729 | + | |
730 | + | |
731 | +@app.get("/") | |
732 | +def root(request): | |
733 | + return {{"message": "Welcome to {name} Analytics API (DuckDB OLAP)!", "docs": "/docs"}} | |
734 | +"# | |
735 | + ); | |
736 | + } | |
737 | + | |
738 | + if db_kind == "clickhouse" { | |
739 | + return format!( | |
740 | + r#""""JustAPI application β {name} | |
741 | + | |
742 | +Database Backend: ClickHouse Columnar Database (URL: {db_url}) | |
743 | +API Architecture: REST | |
744 | +""" | |
745 | +from justapi import JustAPIApp, Schema, HTTPException | |
746 | +from pydantic import Field | |
747 | + | |
748 | +app = JustAPIApp(title="{name}", version="0.1.0") | |
749 | + | |
750 | + | |
751 | +class EventPayload(Schema): | |
752 | + event_name: str = Field(..., description="Event name") | |
753 | + user_id: str = Field(..., description="User ID") | |
754 | + value: int = Field(1, description="Event metric value") | |
755 | + | |
756 | + | |
757 | +@app.get("/health") | |
758 | +def health(request): | |
759 | + return {{"status": "ok", "app": "{name}", "database": "clickhouse"}} | |
760 | + | |
761 | + | |
762 | +@app.post("/analytics/events", body_schema=EventPayload) | |
763 | +def track_event(request): | |
764 | + """Track high-throughput metric event for ClickHouse.""" | |
765 | + data = request.json() | |
766 | + return {{"status": "queued", "event": data}} | |
767 | + | |
768 | + | |
769 | +@app.get("/") | |
770 | +def root(request): | |
771 | + return {{"message": "Welcome to {name} ClickHouse Analytics API!", "docs": "/docs"}} | |
772 | +"# | |
773 | + ); | |
774 | + } | |
775 | + | |
776 | + if db_kind == "mongodb" { | |
777 | + return format!( | |
778 | + r#""""JustAPI application β {name} | |
779 | + | |
780 | +Database backend: NoSQL MongoDB (URL: {db_url}) | |
781 | +API Architecture: REST | |
782 | +""" | |
783 | +from justapi import JustAPIApp, Schema, HTTPException | |
784 | +from pydantic import Field | |
785 | +from pymongo import MongoClient | |
786 | + | |
787 | +app = JustAPIApp(title="{name}", version="0.1.0") | |
788 | + | |
789 | +# Wire MongoDB connection | |
790 | +mongo_client = MongoClient("{db_url}") | |
791 | +db = mongo_client.get_database() | |
792 | +items_col = db["items"] | |
793 | + | |
794 | + | |
795 | +class ItemCreate(Schema): | |
796 | + name: str = Field(..., min_length=1, description="Item name") | |
797 | + qty: int = Field(0, ge=0, description="Quantity in stock") | |
798 | + | |
799 | + | |
800 | +@app.get("/health") | |
801 | +def health(request): | |
802 | + return {{"status": "ok", "app": "{name}", "database": "mongodb"}} | |
803 | + | |
804 | + | |
805 | +@app.get("/items") | |
806 | +def list_items(request): | |
807 | + """List all documents from MongoDB collection.""" | |
808 | + return list(items_col.find({{}}, {{"_id": 0}})) | |
809 | + | |
810 | + | |
811 | +@app.post("/items", body_schema=ItemCreate) | |
812 | +def create_item(request): | |
813 | + """Create a new document in MongoDB.""" | |
814 | + data = request.json() | |
815 | + items_col.insert_one(data.copy()) | |
816 | + return {{"message": "Document created", "item": data}} | |
817 | + | |
818 | + | |
819 | +@app.get("/") | |
820 | +def root(request): | |
821 | + return {{"message": "Welcome to {name} API (MongoDB NoSQL)!", "docs": "/docs"}} | |
822 | +"# | |
823 | + ); | |
824 | + } | |
825 | + | |
826 | + if db_kind == "redis" { | |
827 | + return format!( | |
828 | + r#""""JustAPI application β {name} | |
829 | + | |
830 | +Database backend: NoSQL Redis (URL: {db_url}) | |
831 | +API Architecture: REST | |
832 | +""" | |
833 | +from justapi import JustAPIApp, Schema, HTTPException | |
834 | +from pydantic import Field | |
835 | +import redis | |
836 | + | |
837 | +app = JustAPIApp(title="{name}", version="0.1.0") | |
838 | + | |
839 | +# Wire Redis client connection | |
840 | +r = redis.Redis.from_url("{db_url}", decode_responses=True) | |
841 | + | |
842 | + | |
843 | +class KeyValuePayload(Schema): | |
844 | + key: str = Field(..., min_length=1, description="Storage key") | |
845 | + value: str = Field(..., description="Value to store") | |
846 | + | |
847 | + | |
848 | +@app.get("/health") | |
849 | +def health(request): | |
850 | + return {{"status": "ok", "app": "{name}", "database": "redis"}} | |
851 | + | |
852 | + | |
853 | +@app.get("/kv/{{key}}") | |
854 | +def get_key(request, key: str): | |
855 | + """Get value by key from Redis.""" | |
856 | + val = r.get(key) | |
857 | + if val is None: | |
858 | + raise HTTPException(status_code=404, detail=f"Key '{{key}}' not found") | |
859 | + return {{"key": key, "value": val}} | |
860 | + | |
861 | + | |
862 | +@app.post("/kv", body_schema=KeyValuePayload) | |
863 | +def set_key(request): | |
864 | + """Set key-value pair in Redis.""" | |
865 | + data = request.json() | |
866 | + r.set(data["key"], data["value"]) | |
867 | + return {{"message": "Stored in Redis", "key": data["key"], "value": data["value"]}} | |
868 | + | |
869 | + | |
870 | +@app.get("/") | |
871 | +def root(request): | |
872 | + return {{"message": "Welcome to {name} API (Redis NoSQL)!", "docs": "/docs"}} | |
873 | +"# | |
874 | + ); | |
875 | + } | |
876 | + | |
877 | + let (db_setup, insert_sql, select_sql, delete_sql) = match db_kind { | |
878 | + "postgres" => ( | |
879 | + format!( | |
880 | + "app.set_database(\n \"{db_url}\",\n init_sql=\"\"\"\n CREATE TABLE IF NOT EXISTS items (\n id SERIAL PRIMARY KEY,\n name TEXT NOT NULL,\n qty INT NOT NULL DEFAULT 0\n )\n \"\"\",\n)" | |
881 | + ), | |
882 | + "INSERT INTO items (name, qty) VALUES ($1, $2)", | |
883 | + "SELECT * FROM items WHERE id = $1", | |
884 | + "DELETE FROM items WHERE id = $1" | |
885 | + ), | |
886 | + "mysql" => ( | |
887 | + format!( | |
888 | + "app.set_database(\n \"{db_url}\",\n init_sql=\"\"\"\n CREATE TABLE IF NOT EXISTS items (\n id INT AUTO_INCREMENT PRIMARY KEY,\n name VARCHAR(255) NOT NULL,\n qty INT NOT NULL DEFAULT 0\n )\n \"\"\",\n)" | |
889 | + ), | |
890 | + "INSERT INTO items (name, qty) VALUES (?, ?)", | |
891 | + "SELECT * FROM items WHERE id = ?", | |
892 | + "DELETE FROM items WHERE id = ?" | |
893 | + ), | |
894 | + _ => ( | |
895 | + format!( | |
896 | + "app.set_database(\n \"{db_url}\",\n pragmas=[\"journal_mode=WAL\"],\n init_sql=\"\"\"\n CREATE TABLE IF NOT EXISTS items (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n qty INTEGER NOT NULL DEFAULT 0\n )\n \"\"\",\n)" | |
897 | + ), | |
898 | + "INSERT INTO items (name, qty) VALUES (?, ?)", | |
899 | + "SELECT * FROM items WHERE id = ?", | |
900 | + "DELETE FROM items WHERE id = ?" | |
391 | 901 | ), |
392 | 902 | }; |
903 | + | |
393 | 904 | format!( |
394 | 905 | r#""""JustAPI application β {name} |
395 | 906 | |
396 | -Database backend: {kind} (URL: {url}) | |
907 | +Database backend: {db_kind} (URL: {db_url}) | |
397 | 908 | """ |
398 | -from justapi import JustAPIApp, Database | |
909 | +from justapi import JustAPIApp, Schema, HTTPException | |
910 | +from pydantic import Field | |
911 | + | |
912 | +app = JustAPIApp(title="{name}", version="0.1.0") | |
913 | + | |
914 | +# Wire database backend | |
915 | +{db_setup} | |
916 | + | |
399 | 917 | |
400 | -app = JustAPIApp() | |
918 | +class ItemCreate(Schema): | |
919 | + name: str = Field(..., min_length=1, description="Item name") | |
920 | + qty: int = Field(0, ge=0, description="Quantity in stock") | |
401 | 921 | |
402 | -# Wire the database. `app.db` is available inside handlers once the server runs. | |
403 | -{dbsetup} | |
922 | + | |
923 | +class ItemResponse(Schema): | |
924 | + id: int | |
925 | + name: str | |
926 | + qty: int | |
927 | + | |
928 | + | |
929 | +@app.get("/health") | |
930 | +def health(request): | |
931 | + return {{"status": "ok", "app": "{name}", "database": "{db_kind}"}} | |
404 | 932 | |
405 | 933 | |
406 | 934 | @app.get("/items") |
407 | 935 | def list_items(request): |
408 | - # Runs entirely in Rust (GIL released) with bound parameters. | |
936 | + """List all items in the database (runs in Rust engine).""" | |
409 | 937 | return app.db.query("SELECT * FROM items ORDER BY id") |
410 | 938 | |
411 | 939 | |
412 | -@app.post("/items") | |
413 | -def add_item(request): | |
940 | +@app.get("/items/{{item_id}}") | |
941 | +def get_item(request, item_id: int): | |
942 | + """Get a single item by ID.""" | |
943 | + rows = app.db.query("{select_sql}", [item_id]) | |
944 | + if not rows: | |
945 | + raise HTTPException(status_code=404, detail=f"Item {{item_id}} not found") | |
946 | + return rows[0] | |
947 | + | |
948 | + | |
949 | +@app.post("/items", body_schema=ItemCreate) | |
950 | +def create_item(request): | |
951 | + """Create a new item with request schema validation.""" | |
414 | 952 | data = request.json() |
953 | + name = data["name"] | |
954 | + qty = data.get("qty", 0) | |
955 | + | |
415 | 956 | app.db.execute( |
416 | - "INSERT INTO items (name, qty) VALUES (?, ?)", | |
417 | - [data.get("name"), data.get("qty", 0)], | |
957 | + "{insert_sql}", | |
958 | + [name, qty], | |
418 | 959 | ) |
419 | - return {{"ok": True}} | |
960 | + return {{"message": "Item created successfully", "name": name, "qty": qty}} | |
961 | + | |
962 | + | |
963 | +@app.delete("/items/{{item_id}}") | |
964 | +def delete_item(request, item_id: int): | |
965 | + """Delete an item by ID.""" | |
966 | + res = app.db.execute("{delete_sql}", [item_id]) | |
967 | + return {{"message": f"Item {{item_id}} deleted", "affected": res.rows_affected}} | |
420 | 968 | |
421 | 969 | |
422 | 970 | @app.get("/") |
423 | 971 | def root(request): |
424 | - return {{"message": "Hello from {name}!", "db": "{kind}"}} | |
425 | -"#, | |
426 | - name = name, | |
427 | - kind = db_kind, | |
428 | - url = db_url, | |
429 | - dbsetup = db_setup, | |
972 | + return {{"message": "Welcome to {name} API!", "database": "{db_kind}", "docs": "/docs"}} | |
973 | +"# | |
430 | 974 | ) |
431 | 975 | } |
432 | 976 | |
| @@ -436,6 +980,7 @@ fn scaffold_project( | ||
436 | 980 | output: Option<PathBuf>, |
437 | 981 | db_kind: &str, |
438 | 982 | db_url: &str, |
983 | + api_type: &str, | |
439 | 984 | ) -> anyhow::Result<()> { |
440 | 985 | let project_dir = output.unwrap_or_else(|| PathBuf::from(name)); |
441 | 986 | if project_dir.exists() { |
| @@ -449,25 +994,50 @@ fn scaffold_project( | ||
449 | 994 | std::fs::write(project_dir.join("app").join("__init__.py"), "")?; |
450 | 995 | std::fs::write( |
451 | 996 | project_dir.join("app").join("main.py"), |
452 | - scaffold_main_py(name, db_kind, db_url), | |
997 | + scaffold_main_py(name, db_kind, db_url, api_type), | |
998 | + )?; | |
999 | + std::fs::write( | |
1000 | + project_dir.join("migrations").join("0001_initial.sql"), | |
1001 | + scaffold_migration_sql(name, db_kind), | |
453 | 1002 | )?; |
454 | 1003 | |
455 | 1004 | let env = format!( |
456 | - "# {name} configuration\nHOST=127.0.0.1\nPORT=8080\n# Database engine: {kind}\nDATABASE_URL={url}\n# SECRET_KEY=change-me\n", | |
1005 | + "# {name} configuration\nHOST=127.0.0.1\nPORT=8080\nAPI_TYPE={api_type}\n# Database engine: {kind}\nDATABASE_URL={url}\n# SECRET_KEY=change-me\n", | |
457 | 1006 | name = name, |
1007 | + api_type = api_type, | |
458 | 1008 | kind = db_kind, |
459 | 1009 | url = db_url, |
460 | 1010 | ); |
461 | 1011 | std::fs::write(project_dir.join(".env"), env)?; |
462 | 1012 | |
463 | - std::fs::write( | |
464 | - project_dir.join("requirements.txt"), | |
465 | - "# Add your Python dependencies here\n# justapi is pre-installed with the runtime\n", | |
466 | - )?; | |
1013 | + let reqs = match db_kind { | |
1014 | + "duckdb" => "# Add Python dependencies here\njustapi\npydantic>=2.0\nduckdb>=0.9.0\n", | |
1015 | + "clickhouse" => { | |
1016 | + "# Add Python dependencies here\njustapi\npydantic>=2.0\nclickhouse-driver>=0.2.0\n" | |
1017 | + } | |
1018 | + "mongodb" => "# Add Python dependencies here\njustapi\npydantic>=2.0\npymongo>=4.0\n", | |
1019 | + "redis" => "# Add Python dependencies here\njustapi\npydantic>=2.0\nredis>=5.0\n", | |
1020 | + _ => "# Add Python dependencies here\njustapi\npydantic>=2.0\n", | |
1021 | + }; | |
1022 | + std::fs::write(project_dir.join("requirements.txt"), reqs)?; | |
467 | 1023 | |
468 | 1024 | let readme = format!( |
469 | - "# {name}\n\nA JustAPI application (database backend: {kind}).\n\n## Quick start\n\n```bash\njustapi create {name} --db {kind} # or: justapi new {name}\ncd {dir}\njustapi serve\n```\n\n## Database\n\nConnection: `{url}`\n\n```bash\n# Run migrations\njustapi db migrate --url \"{url}\"\n\n# Start with hot reload\njustapi serve --reload\n```\n", | |
1025 | + "# {name}\n\n\ | |
1026 | + A modern high-performance JustAPI application powered by Rust (Protocol: `{api_type}`, Database: `{kind}`).\n\n\ | |
1027 | + ## Quick start\n\n\ | |
1028 | + ```bash\n\ | |
1029 | + cd {dir}\n\ | |
1030 | + justapi serve --reload\n\ | |
1031 | + ```\n\n\ | |
1032 | + Open http://localhost:8080/docs for interactive OpenAPI documentation.\n\n\ | |
1033 | + ## Database & Connection\n\n\ | |
1034 | + Connection string: `{url}`\n\n\ | |
1035 | + ```bash\n\ | |
1036 | + # Start server with hot reload\n\ | |
1037 | + justapi serve --reload\n\ | |
1038 | + ```\n", | |
470 | 1039 | name = name, |
1040 | + api_type = api_type, | |
471 | 1041 | kind = db_kind, |
472 | 1042 | dir = project_dir.display(), |
473 | 1043 | url = db_url, |
| @@ -476,18 +1046,56 @@ fn scaffold_project( | ||
476 | 1046 | |
477 | 1047 | std::fs::write( |
478 | 1048 | project_dir.join(".gitignore"), |
479 | - "*.pyc\n__pycache__/\n.env\n*.egg-info/\ndist/\nbuild/\n*.db\n", | |
1049 | + "*.pyc\n__pycache__/\n.env\n*.egg-info/\ndist/\nbuild/\n*.db\n*.duckdb\n", | |
480 | 1050 | )?; |
481 | 1051 | |
482 | 1052 | std::fs::write( |
483 | 1053 | project_dir.join("Dockerfile"), |
484 | - "FROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install justapi\nCOPY . .\nEXPOSE 8080\nCMD [\"justapi\", \"serve\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]\n", | |
1054 | + "FROM python:3.12-slim\n\ | |
1055 | + WORKDIR /app\n\ | |
1056 | + COPY requirements.txt .\n\ | |
1057 | + RUN pip install --no-cache-dir -r requirements.txt\n\ | |
1058 | + COPY . .\n\ | |
1059 | + EXPOSE 8080\n\ | |
1060 | + ENV HOST=0.0.0.0 PORT=8080\n\ | |
1061 | + CMD [\"justapi\", \"serve\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]\n", | |
485 | 1062 | )?; |
486 | 1063 | |
487 | - println!("β
Created new JustAPI project '{}' (database: {})", name, db_kind); | |
1064 | + std::fs::write( | |
1065 | + project_dir.join("docker-compose.otel.yml"), | |
1066 | + format!( | |
1067 | + "version: '3.8'\n\n\ | |
1068 | + services:\n \ | |
1069 | + jaeger:\n \ | |
1070 | + image: jaegertracing/all-in-one:latest\n \ | |
1071 | + ports:\n \ | |
1072 | + - \"16686:16686\" # Jaeger UI\n \ | |
1073 | + - \"4317:4317\" # OTLP gRPC receiver\n \ | |
1074 | + environment:\n \ | |
1075 | + - COLLECTOR_OTLP_ENABLED=true\n\n \ | |
1076 | + prometheus:\n \ | |
1077 | + image: prom/prometheus:latest\n \ | |
1078 | + ports:\n \ | |
1079 | + - \"9090:9090\"\n\n \ | |
1080 | + app:\n \ | |
1081 | + build: .\n \ | |
1082 | + ports:\n \ | |
1083 | + - \"8080:8080\"\n \ | |
1084 | + environment:\n \ | |
1085 | + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317\n \ | |
1086 | + - OTEL_SERVICE_NAME={name}\n", | |
1087 | + name = name | |
1088 | + ), | |
1089 | + )?; | |
1090 | + | |
1091 | + println!( | |
1092 | + "β¨ Created new JustAPI project '{}' (API: {}, database: {})", | |
1093 | + name, api_type, db_kind | |
1094 | + ); | |
1095 | + println!(" ββ OpenTelemetry observability compose file: docker-compose.otel.yml"); | |
488 | 1096 | println!(); |
489 | 1097 | println!(" cd {}", project_dir.display()); |
490 | - println!(" justapi serve"); | |
1098 | + println!(" justapi serve --reload"); | |
491 | 1099 | Ok(()) |
492 | 1100 | } |
493 | 1101 | |
| @@ -1143,13 +1751,16 @@ async fn run() -> anyhow::Result<()> { | ||
1143 | 1751 | Ok(()) |
1144 | 1752 | } |
1145 | 1753 | Commands::New { name, output } => { |
1146 | - // Default scaffold: SQLite (zero-config). | |
1147 | - scaffold_project(&name, output, "sqlite", "sqlite://app.db") | |
1754 | + let (kind, url) = resolve_scaffold_db(&name, None, None)?; | |
1755 | + let api_type = prompt_api_type_selection(&name); | |
1756 | + scaffold_project(&name, output, &kind, &url, &api_type) | |
1148 | 1757 | } |
1149 | - Commands::Create { name, output, db, db_url } => { | |
1150 | - let (kind, url) = resolve_scaffold_db(&db, db_url)?; | |
1151 | - scaffold_project(&name, output, &kind, &url) | |
1758 | + Commands::Create { name, output, db, db_url, api_type } => { | |
1759 | + let (kind, url) = resolve_scaffold_db(&name, db.as_deref(), db_url)?; | |
1760 | + let proto = api_type.unwrap_or_else(|| prompt_api_type_selection(&name)); | |
1761 | + scaffold_project(&name, output, &kind, &url, &proto) | |
1152 | 1762 | } |
1763 | + | |
1153 | 1764 | Commands::Profile { addr, duration, connections, output } => { |
1154 | 1765 | println!("π¬ JustAPI Profiler"); |
1155 | 1766 | println!(" Target: {addr}"); |
1156 | 1767 | |