From 9a01b7712c3aedd052a0a4478c7f980c8f777f3c Mon Sep 17 00:00:00 2001 From: Dan Niles Date: Thu, 23 Apr 2026 20:26:50 +0530 Subject: [PATCH 1/5] Add customer order API sample for db persist feature --- .../samples/customer-order-api/Ballerina.toml | 28 ++ .../customer-order-api/Dependencies.toml | 411 ++++++++++++++++++ .../samples/customer-order-api/README.md | 122 ++++++ .../samples/customer-order-api/config.bal | 6 + .../customer-order-api/connections.bal | 3 + .../customer-order-api/db/init/01_schema.sql | 33 ++ .../customer-order-api/db/init/02_seed.sql | 24 + .../customer-order-api/docker-compose.yml | 22 + .../samples/customer-order-api/main.bal | 120 +++++ .../customer-order-api/persist/db/model.bal | 63 +++ 10 files changed, 832 insertions(+) create mode 100644 integrator-default-profile/samples/customer-order-api/Ballerina.toml create mode 100644 integrator-default-profile/samples/customer-order-api/Dependencies.toml create mode 100644 integrator-default-profile/samples/customer-order-api/README.md create mode 100644 integrator-default-profile/samples/customer-order-api/config.bal create mode 100644 integrator-default-profile/samples/customer-order-api/connections.bal create mode 100644 integrator-default-profile/samples/customer-order-api/db/init/01_schema.sql create mode 100644 integrator-default-profile/samples/customer-order-api/db/init/02_seed.sql create mode 100644 integrator-default-profile/samples/customer-order-api/docker-compose.yml create mode 100644 integrator-default-profile/samples/customer-order-api/main.bal create mode 100644 integrator-default-profile/samples/customer-order-api/persist/db/model.bal diff --git a/integrator-default-profile/samples/customer-order-api/Ballerina.toml b/integrator-default-profile/samples/customer-order-api/Ballerina.toml new file mode 100644 index 00000000..d16c4577 --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/Ballerina.toml @@ -0,0 +1,28 @@ +[package] +org = "danniles" +name = "customer_order_api" +version = "0.1.0" +distribution = "2201.13.3" +title = "Customer Order API" + +[build-options] +sticky = true + +[[tool.persist]] +id = "customer_order_api.dbpersist" +targetModule = "customer_order_api.dbpersist" +filePath = "persist/db/model.bal" +options.datastore = "postgresql" +options.eagerLoading = true +options.withInitParams = true + +[[dependency]] +org = "ballerina" +name = "tool.persist" +version = "1.9.1" + +[[platform.java21.dependency]] +groupId = "io.ballerina.stdlib" +artifactId = "persist.sql-native" +version = "1.7.2" + diff --git a/integrator-default-profile/samples/customer-order-api/Dependencies.toml b/integrator-default-profile/samples/customer-order-api/Dependencies.toml new file mode 100644 index 00000000..08cc897b --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/Dependencies.toml @@ -0,0 +1,411 @@ +# AUTO-GENERATED FILE. DO NOT MODIFY. + +# This file is auto-generated by Ballerina for managing dependency versions. +# It should not be modified by hand. + +[ballerina] +dependencies-toml-version = "2" +distribution-version = "2201.13.3" + +[[package]] +org = "ballerina" +name = "auth" +version = "2.14.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.array"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "log"} +] + +[[package]] +org = "ballerina" +name = "cache" +version = "3.10.0" +dependencies = [ + {org = "ballerina", name = "constraint"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "task"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "constraint" +version = "1.7.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "crypto" +version = "2.10.1" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "data.jsondata" +version = "1.1.3" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "file" +version = "1.12.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "os"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "http" +version = "2.16.1" +dependencies = [ + {org = "ballerina", name = "auth"}, + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "constraint"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "data.jsondata"}, + {org = "ballerina", name = "file"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "jwt"}, + {org = "ballerina", name = "lang.array"}, + {org = "ballerina", name = "lang.decimal"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "lang.regexp"}, + {org = "ballerina", name = "lang.runtime"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "lang.value"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "mime"}, + {org = "ballerina", name = "oauth2"}, + {org = "ballerina", name = "observe"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "url"} +] +modules = [ + {org = "ballerina", packageName = "http", moduleName = "http"}, + {org = "ballerina", packageName = "http", moduleName = "http.httpscerr"} +] + +[[package]] +org = "ballerina" +name = "io" +version = "1.8.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.value"} +] + +[[package]] +org = "ballerina" +name = "jballerina.java" +version = "0.0.0" +modules = [ + {org = "ballerina", packageName = "jballerina.java", moduleName = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "jwt" +version = "2.15.1" +dependencies = [ + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "lang.__internal" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "lang.array" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.__internal"} +] + +[[package]] +org = "ballerina" +name = "lang.decimal" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.int" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.__internal"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "lang.object" +version = "0.0.0" + +[[package]] +org = "ballerina" +name = "lang.regexp" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.runtime" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.string" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.regexp"} +] + +[[package]] +org = "ballerina" +name = "lang.value" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "log" +version = "2.17.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.value"}, + {org = "ballerina", name = "observe"} +] + +[[package]] +org = "ballerina" +name = "mime" +version = "2.12.1" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "log"} +] + +[[package]] +org = "ballerina" +name = "oauth2" +version = "2.15.0" +dependencies = [ + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "url"} +] + +[[package]] +org = "ballerina" +name = "observe" +version = "1.7.1" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "os" +version = "1.10.1" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "persist" +version = "1.7.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] +modules = [ + {org = "ballerina", packageName = "persist", moduleName = "persist"} +] + +[[package]] +org = "ballerina" +name = "sql" +version = "1.19.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.object"}, + {org = "ballerina", name = "time"} +] +modules = [ + {org = "ballerina", packageName = "sql", moduleName = "sql"} +] + +[[package]] +org = "ballerina" +name = "task" +version = "2.11.1" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "uuid"} +] + +[[package]] +org = "ballerina" +name = "time" +version = "2.8.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] +modules = [ + {org = "ballerina", packageName = "time", moduleName = "time"} +] + +[[package]] +org = "ballerina" +name = "url" +version = "2.6.1" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "uuid" +version = "1.10.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerinai" +name = "observe" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "observe"} +] + +[[package]] +org = "ballerinax" +name = "cdc" +version = "1.3.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "data.jsondata"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "log"}, + {org = "ballerinai", name = "observe"} +] + +[[package]] +org = "ballerinax" +name = "persist.sql" +version = "1.7.3" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "persist"}, + {org = "ballerina", name = "sql"}, + {org = "ballerina", name = "time"} +] +modules = [ + {org = "ballerinax", packageName = "persist.sql", moduleName = "persist.sql"} +] + +[[package]] +org = "ballerinax" +name = "postgresql" +version = "1.18.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "sql"}, + {org = "ballerina", name = "time"}, + {org = "ballerinax", name = "cdc"} +] +modules = [ + {org = "ballerinax", packageName = "postgresql", moduleName = "postgresql"} +] + +[[package]] +org = "ballerinax" +name = "postgresql.driver" +version = "1.6.3" +modules = [ + {org = "ballerinax", packageName = "postgresql.driver", moduleName = "postgresql.driver"} +] + +[[package]] +org = "danniles" +name = "customer_order_api" +version = "0.1.0" +dependencies = [ + {org = "ballerina", name = "http"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "persist"}, + {org = "ballerina", name = "sql"}, + {org = "ballerina", name = "time"}, + {org = "ballerinax", name = "persist.sql"}, + {org = "ballerinax", name = "postgresql"}, + {org = "ballerinax", name = "postgresql.driver"} +] +modules = [ + {org = "danniles", packageName = "customer_order_api", moduleName = "customer_order_api"}, + {org = "danniles", packageName = "customer_order_api", moduleName = "customer_order_api.dbpersist"} +] + +[[tool]] +id = "persist" +org = "ballerina" +name = "tool.persist" +version = "1.9.2" + diff --git a/integrator-default-profile/samples/customer-order-api/README.md b/integrator-default-profile/samples/customer-order-api/README.md new file mode 100644 index 00000000..5dfb889f --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/README.md @@ -0,0 +1,122 @@ +# Customer Order API + +A minimal integration sample showing how to use a **Ballerina database persist client** from an HTTP service. The database models a small e-commerce schema (customers, products, orders, order items), and the service exposes a REST API that performs basic CRUD and a cross-entity insert via the generated persist client. + +The goal is to be small enough to read end-to-end. There is no auth, pagination, OpenAPI spec, or validation beyond what the sample needs to make sense. + +## What this sample demonstrates + +- Defining entities with `@sql:` annotations in `persist/db/model.bal` +- Generating a typed persist client (`bal persist generate`) and using it from a Ballerina service +- Collection operations — `get`, `post`, `put`, `delete` — on the generated client +- Fetching a single record by primary key +- Filtering a collection with a `whereClause` (customers → their orders) +- Inserting a parent row and child rows together (`Order` + `OrderItem[]`) +- Reusing the generated record types directly as request/response payloads + +## Prerequisites + +- Ballerina 2201.13.3 or newer +- Docker (for the Postgres container) or a local Postgres 16 instance + +## Setup + +### 1. Start Postgres + +```bash +docker compose up -d +``` + +This starts Postgres on `localhost:5432`, creates the `db_persist` database, runs the schema in `db/init/01_schema.sql`, and seeds sample rows from `db/init/02_seed.sql`. + +### 2. Configure the DB password + +`Config.toml` already sets the password for the bundled Postgres container: + +```toml +[danniles.customer_order_api] +dbPassword = "postgres" +``` + +Override `dbHost`, `dbPort`, `dbUser`, or `dbDatabase` there if you are pointing at a different DB. + +### 3. Run the service + +```bash +bal run +``` + +The service listens on `http://localhost:9090/api/v1`. + +## API + +All endpoints are under `/api/v1`. + +### Customers + +```bash +# List +curl http://localhost:9090/api/v1/customers + +# Get one +curl http://localhost:9090/api/v1/customers/1 + +# Create +curl -X POST http://localhost:9090/api/v1/customers \ + -H 'Content-Type: application/json' \ + -d '{"name": "Eve Patel", "email": "eve@example.com"}' + +# Update (any subset of fields) +curl -X PUT http://localhost:9090/api/v1/customers/1 \ + -H 'Content-Type: application/json' \ + -d '{"name": "Alice J."}' + +# Delete +curl -X DELETE http://localhost:9090/api/v1/customers/1 + +# Orders for a customer +curl http://localhost:9090/api/v1/customers/1/orders +``` + +### Products + +```bash +curl http://localhost:9090/api/v1/products +``` + +### Orders + +Create an order with one or more line items. The service looks up each product, uses its current price as the unit price, and computes the order total. + +```bash +curl -X POST http://localhost:9090/api/v1/orders \ + -H 'Content-Type: application/json' \ + -d '{ + "customerId": 1, + "status": "pending", + "items": [ + {"productId": 1, "quantity": 2}, + {"productId": 4, "quantity": 1} + ] + }' +``` + +`status` is optional and defaults to `"pending"`. + +## Regenerating the persist client + +If you edit `persist/db/model.bal`, regenerate the client: + +```bash +bal persist generate +``` + +`bal build` also runs this automatically. + +## Ideas to extend this sample + +- Wrap `POST /orders` in a persist transaction so the order and its items commit atomically +- Add a `GET /orders/{id}` that returns the order with its items (`OrderWithRelations`) +- Decrement `Product.stock` when an order is placed +- Add pagination via `limitClause` and `orderByClause` +- Add a scheduled job that produces a daily sales summary diff --git a/integrator-default-profile/samples/customer-order-api/config.bal b/integrator-default-profile/samples/customer-order-api/config.bal new file mode 100644 index 00000000..7d0a804e --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/config.bal @@ -0,0 +1,6 @@ + +configurable string dbHost = "localhost"; +configurable int dbPort = 5432; +configurable string dbUser = "postgres"; +configurable string dbPassword = ?; +configurable string dbDatabase = "db_persist"; diff --git a/integrator-default-profile/samples/customer-order-api/connections.bal b/integrator-default-profile/samples/customer-order-api/connections.bal new file mode 100644 index 00000000..0a96bce5 --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/connections.bal @@ -0,0 +1,3 @@ +import customer_order_api.dbpersist; + +final dbpersist:Client customerDb = check new (dbHost, dbPort, dbUser, dbPassword, dbDatabase); diff --git a/integrator-default-profile/samples/customer-order-api/db/init/01_schema.sql b/integrator-default-profile/samples/customer-order-api/db/init/01_schema.sql new file mode 100644 index 00000000..d679f3b5 --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/db/init/01_schema.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS customers ( + "id" SERIAL PRIMARY KEY, + "name" VARCHAR(120) NOT NULL, + "email" VARCHAR(160) NOT NULL UNIQUE, + "createdAt" TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS products ( + "id" SERIAL PRIMARY KEY, + "sku" VARCHAR(60) NOT NULL UNIQUE, + "name" VARCHAR(160) NOT NULL, + "price" DECIMAL(10,2) NOT NULL CHECK ("price" >= 0), + "stock" INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS orders ( + "id" SERIAL PRIMARY KEY, + "customerId" INT NOT NULL REFERENCES customers("id") ON DELETE CASCADE, + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "total" DECIMAL(10,2) NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS order_items ( + "id" SERIAL PRIMARY KEY, + "orderId" INT NOT NULL REFERENCES orders("id") ON DELETE CASCADE, + "productId" INT NOT NULL REFERENCES products("id"), + "quantity" INT NOT NULL CHECK ("quantity" > 0), + "unitPrice" DECIMAL(10,2) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_orders_customer_id ON orders("customerId"); +CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items("orderId"); diff --git a/integrator-default-profile/samples/customer-order-api/db/init/02_seed.sql b/integrator-default-profile/samples/customer-order-api/db/init/02_seed.sql new file mode 100644 index 00000000..d95a27a4 --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/db/init/02_seed.sql @@ -0,0 +1,24 @@ +INSERT INTO customers ("name", "email") VALUES + ('Alice Johnson', 'alice@example.com'), + ('Bob Martinez', 'bob@example.com'), + ('Carol Nguyen', 'carol@example.com'), + ('David Okafor', 'david@example.com'); + +INSERT INTO products ("sku", "name", "price", "stock") VALUES + ('SKU-001', 'Wireless Mouse', 25.99, 120), + ('SKU-002', 'Mechanical Keyboard', 89.50, 45), + ('SKU-003', '27" Monitor', 329.00, 18), + ('SKU-004', 'USB-C Hub', 39.95, 80), + ('SKU-005', 'Laptop Stand', 49.00, 60); + +INSERT INTO orders ("customerId", "status", "total") VALUES + (1, 'paid', 115.49), + (2, 'pending', 329.00), + (3, 'shipped', 64.94); + +INSERT INTO order_items ("orderId", "productId", "quantity", "unitPrice") VALUES + (1, 1, 1, 25.99), + (1, 2, 1, 89.50), + (2, 3, 1, 329.00), + (3, 4, 1, 39.95), + (3, 1, 1, 24.99); diff --git a/integrator-default-profile/samples/customer-order-api/docker-compose.yml b/integrator-default-profile/samples/customer-order-api/docker-compose.yml new file mode 100644 index 00000000..3758b474 --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/docker-compose.yml @@ -0,0 +1,22 @@ +services: + postgres: + image: postgres:16-alpine + container_name: db_persist_postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: db_persist + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d db_persist"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/integrator-default-profile/samples/customer-order-api/main.bal b/integrator-default-profile/samples/customer-order-api/main.bal new file mode 100644 index 00000000..9ad497f3 --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/main.bal @@ -0,0 +1,120 @@ +import customer_order_api.dbpersist; + +import ballerina/http; +import ballerina/persist; +import ballerina/time; + +service /api/v1 on new http:Listener(9090) { + + // Customers + + resource function get customers() returns dbpersist:Customer[]|error { + return customerDb->/customers.get(); + } + + resource function get customers/[int id]() returns dbpersist:Customer|http:NotFound|error { + dbpersist:Customer|persist:Error result = customerDb->/customers/[id].get(); + if result is persist:NotFoundError { + return http:NOT_FOUND; + } + return result; + } + + resource function post customers(NewCustomer payload) returns dbpersist:Customer|error { + int[] ids = check customerDb->/customers.post([ + {name: payload.name, email: payload.email, createdAt: time:utcNow()} + ]); + return customerDb->/customers/[ids[0]].get(); + } + + resource function put customers/[int id](dbpersist:CustomerUpdate payload) + returns dbpersist:Customer|http:NotFound|error { + dbpersist:Customer|persist:Error result = customerDb->/customers/[id].put(payload); + if result is persist:NotFoundError { + return http:NOT_FOUND; + } + return result; + } + + resource function delete customers/[int id]() + returns dbpersist:Customer|http:NotFound|error { + dbpersist:Customer|persist:Error result = customerDb->/customers/[id].delete(); + if result is persist:NotFoundError { + return http:NOT_FOUND; + } + return result; + } + + resource function get customers/[int id]/orders() returns dbpersist:Order[]|error { + dbpersist:Order[] orders = check customerDb->/orders.get(whereClause = `"customerId" = ${id}`); + return orders; + } + + // Products + + resource function get products() returns dbpersist:Product[]|error { + return customerDb->/products.get(); + } + + // Orders + + resource function post orders(NewOrder payload) + returns dbpersist:Order|http:BadRequest|error { + if payload.items.length() == 0 { + return {body: "order must contain at least one item"}; + } + + decimal total = 0; + dbpersist:OrderItemInsert[] itemInserts = []; + foreach NewOrderItem item in payload.items { + dbpersist:Product|persist:Error product = customerDb->/products/[item.productId].get(); + if product is persist:NotFoundError { + return {body: string `product ${item.productId} not found`}; + } + if product is persist:Error { + return product; + } + total += product.price * item.quantity; + itemInserts.push({ + orderId: 0, + productId: product.id, + quantity: item.quantity, + unitPrice: product.price + }); + } + + int[] orderIds = check customerDb->/orders.post([ + { + customerId: payload.customerId, + status: payload.status ?: "pending", + total: total, + createdAt: time:utcNow() + } + ]); + int orderId = orderIds[0]; + + foreach int i in 0 ..< itemInserts.length() { + itemInserts[i].orderId = orderId; + } + _ = check customerDb->/orderitems.post(itemInserts); + + dbpersist:Order created = check customerDb->/orders/[orderId].get(); + return created; + } +} + +public type NewCustomer record {| + string name; + string email; +|}; + +public type NewOrderItem record {| + int productId; + int quantity; +|}; + +public type NewOrder record {| + int customerId; + string status?; + NewOrderItem[] items; +|}; diff --git a/integrator-default-profile/samples/customer-order-api/persist/db/model.bal b/integrator-default-profile/samples/customer-order-api/persist/db/model.bal new file mode 100644 index 00000000..c980d978 --- /dev/null +++ b/integrator-default-profile/samples/customer-order-api/persist/db/model.bal @@ -0,0 +1,63 @@ +import ballerina/persist as _; +import ballerina/time; +import ballerinax/persist.sql; + +@sql:Name {value: "orders"} +public type Order record {| + @sql:Generated + readonly int id; + @sql:Index {name: "idx_orders_customer_id"} + int customerId; + @sql:Varchar {length: 20} + string status; + @sql:Decimal {precision: [10, 2]} + decimal total; + time:Utc createdAt; + OrderItem[] orderitems; + @sql:Relation {keys: ["customerId"]} + Customer customer; +|}; + +@sql:Name {value: "customers"} +public type Customer record {| + @sql:Generated + readonly int id; + @sql:Varchar {length: 120} + string name; + @sql:Varchar {length: 160} + @sql:UniqueIndex {name: "customers_email_key"} + string email; + time:Utc createdAt; + Order[] orders; +|}; + +@sql:Name {value: "order_items"} +public type OrderItem record {| + @sql:Generated + readonly int id; + @sql:Index {name: "idx_order_items_order_id"} + int orderId; + int productId; + int quantity; + @sql:Decimal {precision: [10, 2]} + decimal unitPrice; + @sql:Relation {keys: ["orderId"]} + Order 'order; + @sql:Relation {keys: ["productId"]} + Product product; +|}; + +@sql:Name {value: "products"} +public type Product record {| + @sql:Generated + readonly int id; + @sql:Varchar {length: 60} + @sql:UniqueIndex {name: "products_sku_key"} + string sku; + @sql:Varchar {length: 160} + string name; + @sql:Decimal {precision: [10, 2]} + decimal price; + int stock; + OrderItem[] orderitems; +|}; From abd82a12c74045ebb9e9ad54df1dadb20bca873e Mon Sep 17 00:00:00 2001 From: Dan Niles Date: Fri, 24 Apr 2026 14:28:56 +0530 Subject: [PATCH 2/5] Address code review comments --- .../samples/customer-order-api/Ballerina.toml | 4 +- .../customer-order-api/Dependencies.toml | 36 +++++++++++++-- .../samples/customer-order-api/README.md | 9 ++-- .../customer-order-api/db/init/02_seed.sql | 4 +- .../samples/customer-order-api/main.bal | 46 ++++++++++++++----- 5 files changed, 75 insertions(+), 24 deletions(-) diff --git a/integrator-default-profile/samples/customer-order-api/Ballerina.toml b/integrator-default-profile/samples/customer-order-api/Ballerina.toml index d16c4577..d63f26d1 100644 --- a/integrator-default-profile/samples/customer-order-api/Ballerina.toml +++ b/integrator-default-profile/samples/customer-order-api/Ballerina.toml @@ -1,5 +1,5 @@ [package] -org = "danniles" +org = "wso2" name = "customer_order_api" version = "0.1.0" distribution = "2201.13.3" @@ -19,7 +19,7 @@ options.withInitParams = true [[dependency]] org = "ballerina" name = "tool.persist" -version = "1.9.1" +version = "1.9.2" [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" diff --git a/integrator-default-profile/samples/customer-order-api/Dependencies.toml b/integrator-default-profile/samples/customer-order-api/Dependencies.toml index 08cc897b..fd0b9b8f 100644 --- a/integrator-default-profile/samples/customer-order-api/Dependencies.toml +++ b/integrator-default-profile/samples/customer-order-api/Dependencies.toml @@ -198,6 +198,14 @@ dependencies = [ {org = "ballerina", name = "lang.regexp"} ] +[[package]] +org = "ballerina" +name = "lang.transaction" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + [[package]] org = "ballerina" name = "lang.value" @@ -332,6 +340,27 @@ dependencies = [ {org = "ballerina", name = "observe"} ] +[[package]] +org = "ballerinai" +name = "transaction" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "http"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.runtime"}, + {org = "ballerina", name = "lang.transaction"}, + {org = "ballerina", name = "lang.value"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "task"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "uuid"} +] +modules = [ + {org = "ballerinai", packageName = "transaction", moduleName = "transaction"} +] + [[package]] org = "ballerinax" name = "cdc" @@ -385,7 +414,7 @@ modules = [ ] [[package]] -org = "danniles" +org = "wso2" name = "customer_order_api" version = "0.1.0" dependencies = [ @@ -394,13 +423,14 @@ dependencies = [ {org = "ballerina", name = "persist"}, {org = "ballerina", name = "sql"}, {org = "ballerina", name = "time"}, + {org = "ballerinai", name = "transaction"}, {org = "ballerinax", name = "persist.sql"}, {org = "ballerinax", name = "postgresql"}, {org = "ballerinax", name = "postgresql.driver"} ] modules = [ - {org = "danniles", packageName = "customer_order_api", moduleName = "customer_order_api"}, - {org = "danniles", packageName = "customer_order_api", moduleName = "customer_order_api.dbpersist"} + {org = "wso2", packageName = "customer_order_api", moduleName = "customer_order_api"}, + {org = "wso2", packageName = "customer_order_api", moduleName = "customer_order_api.dbpersist"} ] [[tool]] diff --git a/integrator-default-profile/samples/customer-order-api/README.md b/integrator-default-profile/samples/customer-order-api/README.md index 5dfb889f..3f90ff6b 100644 --- a/integrator-default-profile/samples/customer-order-api/README.md +++ b/integrator-default-profile/samples/customer-order-api/README.md @@ -11,7 +11,7 @@ The goal is to be small enough to read end-to-end. There is no auth, pagination, - Collection operations — `get`, `post`, `put`, `delete` — on the generated client - Fetching a single record by primary key - Filtering a collection with a `whereClause` (customers → their orders) -- Inserting a parent row and child rows together (`Order` + `OrderItem[]`) +- Creating an `Order` first and then inserting its `OrderItem[]` rows in a follow-up call, wrapped in a single Ballerina transaction - Reusing the generated record types directly as request/response payloads ## Prerequisites @@ -31,14 +31,14 @@ This starts Postgres on `localhost:5432`, creates the `db_persist` database, run ### 2. Configure the DB password -`Config.toml` already sets the password for the bundled Postgres container: +Create a `Config.toml` in the sample root with the password for the bundled Postgres container: ```toml -[danniles.customer_order_api] +[wso2.customer_order_api] dbPassword = "postgres" ``` -Override `dbHost`, `dbPort`, `dbUser`, or `dbDatabase` there if you are pointing at a different DB. +Replace `wso2` with the `org` value from your `Ballerina.toml` if you have changed it. Override `dbHost`, `dbPort`, `dbUser`, or `dbDatabase` in the same section if you are pointing at a different DB. ### 3. Run the service @@ -115,7 +115,6 @@ bal persist generate ## Ideas to extend this sample -- Wrap `POST /orders` in a persist transaction so the order and its items commit atomically - Add a `GET /orders/{id}` that returns the order with its items (`OrderWithRelations`) - Decrement `Product.stock` when an order is placed - Add pagination via `limitClause` and `orderByClause` diff --git a/integrator-default-profile/samples/customer-order-api/db/init/02_seed.sql b/integrator-default-profile/samples/customer-order-api/db/init/02_seed.sql index d95a27a4..4664a054 100644 --- a/integrator-default-profile/samples/customer-order-api/db/init/02_seed.sql +++ b/integrator-default-profile/samples/customer-order-api/db/init/02_seed.sql @@ -14,11 +14,11 @@ INSERT INTO products ("sku", "name", "price", "stock") VALUES INSERT INTO orders ("customerId", "status", "total") VALUES (1, 'paid', 115.49), (2, 'pending', 329.00), - (3, 'shipped', 64.94); + (3, 'shipped', 65.94); INSERT INTO order_items ("orderId", "productId", "quantity", "unitPrice") VALUES (1, 1, 1, 25.99), (1, 2, 1, 89.50), (2, 3, 1, 329.00), (3, 4, 1, 39.95), - (3, 1, 1, 24.99); + (3, 1, 1, 25.99); diff --git a/integrator-default-profile/samples/customer-order-api/main.bal b/integrator-default-profile/samples/customer-order-api/main.bal index 9ad497f3..a54434ce 100644 --- a/integrator-default-profile/samples/customer-order-api/main.bal +++ b/integrator-default-profile/samples/customer-order-api/main.bal @@ -63,6 +63,21 @@ service /api/v1 on new http:Listener(9090) { if payload.items.length() == 0 { return {body: "order must contain at least one item"}; } + foreach NewOrderItem item in payload.items { + if item.quantity <= 0 { + return { + body: string `item quantity must be positive (productId=${item.productId}, quantity=${item.quantity})` + }; + } + } + + dbpersist:Customer|persist:Error customer = customerDb->/customers/[payload.customerId].get(); + if customer is persist:NotFoundError { + return {body: string `customer ${payload.customerId} not found`}; + } + if customer is persist:Error { + return customer; + } decimal total = 0; dbpersist:OrderItemInsert[] itemInserts = []; @@ -83,22 +98,29 @@ service /api/v1 on new http:Listener(9090) { }); } - int[] orderIds = check customerDb->/orders.post([ - { - customerId: payload.customerId, - status: payload.status ?: "pending", - total: total, - createdAt: time:utcNow() + int createdOrderId = 0; + transaction { + int[] orderIds = check customerDb->/orders.post([ + { + customerId: payload.customerId, + status: payload.status ?: "pending", + total: total, + createdAt: time:utcNow() + } + ]); + createdOrderId = orderIds[0]; + + foreach int i in 0 ..< itemInserts.length() { + itemInserts[i].orderId = createdOrderId; } - ]); - int orderId = orderIds[0]; + _ = check customerDb->/orderitems.post(itemInserts); - foreach int i in 0 ..< itemInserts.length() { - itemInserts[i].orderId = orderId; + check commit; + } on fail error e { + return e; } - _ = check customerDb->/orderitems.post(itemInserts); - dbpersist:Order created = check customerDb->/orders/[orderId].get(); + dbpersist:Order created = check customerDb->/orders/[createdOrderId].get(); return created; } } From 572ef34c4639dafc7f2277ee177c59090f6ec34e Mon Sep 17 00:00:00 2001 From: Dan Niles Date: Fri, 24 Apr 2026 14:39:26 +0530 Subject: [PATCH 3/5] Refactor README and config files for clarity and organization --- .../samples/customer-order-api/Ballerina.toml | 1 - .../samples/customer-order-api/README.md | 25 ++++++++++++++++++- .../samples/customer-order-api/config.bal | 1 - 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/integrator-default-profile/samples/customer-order-api/Ballerina.toml b/integrator-default-profile/samples/customer-order-api/Ballerina.toml index d63f26d1..21c82069 100644 --- a/integrator-default-profile/samples/customer-order-api/Ballerina.toml +++ b/integrator-default-profile/samples/customer-order-api/Ballerina.toml @@ -25,4 +25,3 @@ version = "1.9.2" groupId = "io.ballerina.stdlib" artifactId = "persist.sql-native" version = "1.7.2" - diff --git a/integrator-default-profile/samples/customer-order-api/README.md b/integrator-default-profile/samples/customer-order-api/README.md index 3f90ff6b..1426dc51 100644 --- a/integrator-default-profile/samples/customer-order-api/README.md +++ b/integrator-default-profile/samples/customer-order-api/README.md @@ -1,5 +1,7 @@ # Customer Order API +## Description + A minimal integration sample showing how to use a **Ballerina database persist client** from an HTTP service. The database models a small e-commerce schema (customers, products, orders, order items), and the service exposes a REST API that performs basic CRUD and a cross-entity insert via the generated persist client. The goal is to be small enough to read end-to-end. There is no auth, pagination, OpenAPI spec, or validation beyond what the sample needs to make sense. @@ -19,7 +21,7 @@ The goal is to be small enough to read end-to-end. There is no auth, pagination, - Ballerina 2201.13.3 or newer - Docker (for the Postgres container) or a local Postgres 16 instance -## Setup +## Usage Instructions ### 1. Start Postgres @@ -48,6 +50,22 @@ bal run The service listens on `http://localhost:9090/api/v1`. +### Deploy on the **WSO2 Integration Platform** + +1. Deploy this integration on the **WSO2 Integration Platform** as an **Integration as API**. +2. Host a Postgres database for the application. You can create one directly on the **WSO2 Integration Platform** — in the [**Console**](https://console.devant.dev), select your **Organization** and go to the **Databases** section under the **Admin** tab. +3. Run `db/init/01_schema.sql` (and optionally `db/init/02_seed.sql`) against the database to initialize the schema and sample data. +4. In the **Overview** section of the newly created integration, configure `dbHost`, `dbPort`, `dbUser`, `dbPassword`, and `dbDatabase` before deploying. +5. Once deployed, click on **"Test"** to try out the API. + +## How It Works + +- The service listens on port `9090` under the `/api/v1` base path. +- On startup, a typed `persist` client is created from the entities defined in `persist/db/model.bal` and pointed at the configured Postgres instance. +- Each resource function maps directly to an operation on the generated client — for example, `GET /customers` returns `customerDb->/customers.get()`. +- Creating an order is wrapped in a Ballerina `transaction` block: the `Order` row is inserted first to obtain its primary key, then the `OrderItem[]` rows are inserted with that key and the order total (computed from current product prices) is persisted in the same commit. +- Requests for records that do not exist return `404 Not Found`; invalid order payloads (empty items, non-positive quantity, unknown customer or product) return `400 Bad Request`. + ## API All endpoints are under `/api/v1`. @@ -119,3 +137,8 @@ bal persist generate - Decrement `Product.stock` when an order is placed - Add pagination via `limitClause` and `orderByClause` - Add a scheduled job that produces a daily sales summary + +## References + +- [Ballerina `persist` library](https://ballerina.io/learn/persist-your-data-with-ballerina/) +- [Develop an Integration as an API](https://wso2.com/devant/docs/quick-start-guides/develop-an-integration-as-an-api/) on the WSO2 Integration Platform diff --git a/integrator-default-profile/samples/customer-order-api/config.bal b/integrator-default-profile/samples/customer-order-api/config.bal index 7d0a804e..d7366517 100644 --- a/integrator-default-profile/samples/customer-order-api/config.bal +++ b/integrator-default-profile/samples/customer-order-api/config.bal @@ -1,4 +1,3 @@ - configurable string dbHost = "localhost"; configurable int dbPort = 5432; configurable string dbUser = "postgres"; From a61b0c506b01e96c264cc90dba33d2aa4afba0b4 Mon Sep 17 00:00:00 2001 From: Dan Niles Date: Fri, 24 Apr 2026 14:49:39 +0530 Subject: [PATCH 4/5] Update README --- .../samples/customer-order-api/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integrator-default-profile/samples/customer-order-api/README.md b/integrator-default-profile/samples/customer-order-api/README.md index 1426dc51..aa52f91a 100644 --- a/integrator-default-profile/samples/customer-order-api/README.md +++ b/integrator-default-profile/samples/customer-order-api/README.md @@ -2,18 +2,18 @@ ## Description -A minimal integration sample showing how to use a **Ballerina database persist client** from an HTTP service. The database models a small e-commerce schema (customers, products, orders, order items), and the service exposes a REST API that performs basic CRUD and a cross-entity insert via the generated persist client. +A minimal integration sample showing how to use a database `persist` client from an HTTP service. The database models a small e-commerce schema (customers, products, orders, order items), and the service exposes a REST API that performs basic CRUD and a cross-entity insert via the generated persist client. The goal is to be small enough to read end-to-end. There is no auth, pagination, OpenAPI spec, or validation beyond what the sample needs to make sense. ## What this sample demonstrates - Defining entities with `@sql:` annotations in `persist/db/model.bal` -- Generating a typed persist client (`bal persist generate`) and using it from a Ballerina service +- Generating a typed persist client (`bal persist generate`) and using it from an HTTP service - Collection operations — `get`, `post`, `put`, `delete` — on the generated client - Fetching a single record by primary key - Filtering a collection with a `whereClause` (customers → their orders) -- Creating an `Order` first and then inserting its `OrderItem[]` rows in a follow-up call, wrapped in a single Ballerina transaction +- Creating an `Order` first and then inserting its `OrderItem[]` rows in a follow-up call, wrapped in a single `transaction` block - Reusing the generated record types directly as request/response payloads ## Prerequisites @@ -63,7 +63,7 @@ The service listens on `http://localhost:9090/api/v1`. - The service listens on port `9090` under the `/api/v1` base path. - On startup, a typed `persist` client is created from the entities defined in `persist/db/model.bal` and pointed at the configured Postgres instance. - Each resource function maps directly to an operation on the generated client — for example, `GET /customers` returns `customerDb->/customers.get()`. -- Creating an order is wrapped in a Ballerina `transaction` block: the `Order` row is inserted first to obtain its primary key, then the `OrderItem[]` rows are inserted with that key and the order total (computed from current product prices) is persisted in the same commit. +- Creating an order is wrapped in a `transaction` block: the `Order` row is inserted first to obtain its primary key, then the `OrderItem[]` rows are inserted with that key and the order total (computed from current product prices) is persisted in the same commit. - Requests for records that do not exist return `404 Not Found`; invalid order payloads (empty items, non-positive quantity, unknown customer or product) return `400 Bad Request`. ## API From 20fb8fceb891779e7136cf59590212fde7af5dce Mon Sep 17 00:00:00 2001 From: Dan Niles Date: Fri, 24 Apr 2026 15:12:05 +0530 Subject: [PATCH 5/5] Refactor customer and product retrieval to store results in local variables --- .../samples/customer-order-api/main.bal | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/integrator-default-profile/samples/customer-order-api/main.bal b/integrator-default-profile/samples/customer-order-api/main.bal index a54434ce..63044cad 100644 --- a/integrator-default-profile/samples/customer-order-api/main.bal +++ b/integrator-default-profile/samples/customer-order-api/main.bal @@ -9,7 +9,8 @@ service /api/v1 on new http:Listener(9090) { // Customers resource function get customers() returns dbpersist:Customer[]|error { - return customerDb->/customers.get(); + dbpersist:Customer[] customers = check customerDb->/customers.get(); + return customers; } resource function get customers/[int id]() returns dbpersist:Customer|http:NotFound|error { @@ -24,7 +25,8 @@ service /api/v1 on new http:Listener(9090) { int[] ids = check customerDb->/customers.post([ {name: payload.name, email: payload.email, createdAt: time:utcNow()} ]); - return customerDb->/customers/[ids[0]].get(); + dbpersist:Customer created = check customerDb->/customers/[ids[0]].get(); + return created; } resource function put customers/[int id](dbpersist:CustomerUpdate payload) @@ -53,7 +55,8 @@ service /api/v1 on new http:Listener(9090) { // Products resource function get products() returns dbpersist:Product[]|error { - return customerDb->/products.get(); + dbpersist:Product[] products = check customerDb->/products.get(); + return products; } // Orders