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
β–Ό.github/workflows/wheels.yml
+86-0
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
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
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
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
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
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
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
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