SQLite API
This example shows how to build a small Vix JSON API backed by SQLite.
It uses the Vix database module:
#include <vix/db.hpp>The example uses SQLite because it does not require a separate database server.
You will build:
GET /api/health
GET /api/products
GET /api/products/{id}
POST /api/products
PATCH /api/products/{id}
DELETE /api/products/{id}The database file is stored at:
storage/app.dbWhy SQLite
SQLite is a good default for examples, local tools, tests, prototypes, and local-first applications.
It gives you a real SQL database without requiring:
a database server
a network connection
a database daemon
a separate setup stepFor this example, that means you can create a small API, store real rows, inspect the file, and reset the database by deleting it.
Project structure
Create this structure:
sqlite_api_demo/
├── sqlite_api.cpp
└── storage/Create the directory:
mkdir -p sqlite_api_demo/storage
cd sqlite_api_demo
touch sqlite_api.cppSource
Open:
sqlite_api.cppAdd:
#include <exception>
#include <optional>
#include <string>
#include <vector>
#include <vix.hpp>
#include <vix/db.hpp>
#include <vix/json.hpp>
#include <vix/middleware.hpp>
using namespace vix;
struct Product
{
long long id{};
std::string name{};
double price{};
bool available{};
std::string created_at{};
};
static vix::json::Json product_to_json(const Product &product)
{
using namespace vix::json;
return o(
"id", product.id,
"name", product.name,
"price", product.price,
"available", product.available,
"created_at", product.created_at
);
}
static void initialize_schema(vix::db::Database &db)
{
db.exec(
"CREATE TABLE IF NOT EXISTS products ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"name TEXT NOT NULL,"
"price REAL NOT NULL,"
"available INTEGER NOT NULL DEFAULT 1,"
"created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"
")"
);
}
static Product read_product(const vix::db::ResultRow &row)
{
Product product;
product.id = row.getInt64(0);
product.name = row.getString(1);
product.price = row.getDouble(2);
product.available = row.getInt64(3) != 0;
product.created_at = row.getString(4);
return product;
}
static std::vector<Product> list_products(vix::db::Database &db)
{
auto rows = db.query(
"SELECT id, name, price, available, created_at "
"FROM products "
"ORDER BY id DESC"
);
std::vector<Product> products;
while (rows->next())
{
products.push_back(read_product(rows->row()));
}
return products;
}
static std::optional<Product> find_product(
vix::db::Database &db,
long long id)
{
auto rows = db.query(
"SELECT id, name, price, available, created_at "
"FROM products "
"WHERE id = ?",
id
);
if (!rows->next())
return std::nullopt;
return read_product(rows->row());
}
static Product create_product(
vix::db::Database &db,
const std::string &name,
double price,
bool available)
{
db.exec(
"INSERT INTO products (name, price, available) "
"VALUES (?, ?, ?)",
name,
price,
available
);
auto rows = db.query(
"SELECT id, name, price, available, created_at "
"FROM products "
"WHERE id = last_insert_rowid()"
);
if (!rows->next())
throw std::runtime_error("created product could not be loaded");
return read_product(rows->row());
}
static bool update_product(
vix::db::Database &db,
long long id,
const std::string &name,
double price,
bool available)
{
const auto affected = db.exec(
"UPDATE products "
"SET name = ?, price = ?, available = ? "
"WHERE id = ?",
name,
price,
available,
id
);
return affected > 0;
}
static bool delete_product(vix::db::Database &db, long long id)
{
const auto affected = db.exec(
"DELETE FROM products WHERE id = ?",
id
);
return affected > 0;
}
static long long parse_id(const std::string &value)
{
try
{
return std::stoll(value);
}
catch (...)
{
return 0;
}
}
static void send_validation_error(
Response &res,
const std::string &field,
const std::string &message)
{
res.status(422).json({
"ok", false,
"error", message,
"field", field
});
}
static bool validate_product_input(
Response &res,
const std::optional<std::string> &name,
double price)
{
if (!name || name->empty())
{
send_validation_error(res, "name", "Missing required field");
return false;
}
if (price <= 0.0)
{
send_validation_error(res, "price", "Price must be greater than zero");
return false;
}
return true;
}
static void install_middleware(App &app)
{
app.use("/api", middleware::app::recovery_dev());
app.use("/api", middleware::app::request_id_dev());
app.use("/api", middleware::app::timing_dev());
app.use("/api", middleware::app::security_headers_dev());
app.use("/api", middleware::app::rate_limit_dev());
app.use("/api", middleware::app::body_limit_write_dev(1024 * 1024));
app.use("/api/products", middleware::app::json_strict_dev(
4096,
false,
true
));
}
static void register_routes(App &app, vix::db::Database &db)
{
app.get("/", [](Request &, Response &res)
{
res.text("SQLite API example. Try /api/products.");
});
app.get("/api/health", [](Request &, Response &res)
{
res.json({
"ok", true,
"service", "sqlite-api"
});
});
app.get("/api/products", [&db](Request &, Response &res)
{
using namespace vix::json;
Json items = arr();
for (const auto &product : list_products(db))
{
items.push_back(product_to_json(product));
}
res.json(o(
"ok", true,
"products", items
));
});
app.get("/api/products/{id}", [&db](Request &req, Response &res)
{
using namespace vix::json;
const long long id = parse_id(req.param("id"));
if (id <= 0)
{
res.status(400).json({
"ok", false,
"error", "Invalid product id"
});
return;
}
auto product = find_product(db, id);
if (!product)
{
res.status(404).json({
"ok", false,
"error", "Product not found"
});
return;
}
res.json(o(
"ok", true,
"product", product_to_json(*product)
));
});
app.post("/api/products", [&db](Request &req, Response &res)
{
using namespace vix::json;
auto &body =
req.state<middleware::parsers::JsonBody>();
auto name = get_opt<std::string>(body.value, "name");
const double price = get_or<double>(body.value, "price", 0.0);
const bool available = get_or<bool>(body.value, "available", true);
if (!validate_product_input(res, name, price))
return;
Product product =
create_product(db, *name, price, available);
res.status(201).json(o(
"ok", true,
"product", product_to_json(product)
));
});
app.patch("/api/products/{id}", [&db](Request &req, Response &res)
{
using namespace vix::json;
const long long id = parse_id(req.param("id"));
if (id <= 0)
{
res.status(400).json({
"ok", false,
"error", "Invalid product id"
});
return;
}
auto &body =
req.state<middleware::parsers::JsonBody>();
auto name = get_opt<std::string>(body.value, "name");
const double price = get_or<double>(body.value, "price", 0.0);
const bool available = get_or<bool>(body.value, "available", true);
if (!validate_product_input(res, name, price))
return;
const bool updated =
update_product(db, id, *name, price, available);
if (!updated)
{
res.status(404).json({
"ok", false,
"error", "Product not found"
});
return;
}
auto product = find_product(db, id);
res.json(o(
"ok", true,
"product", product_to_json(*product)
));
});
app.del("/api/products/{id}", [&db](Request &req, Response &res)
{
const long long id = parse_id(req.param("id"));
if (id <= 0)
{
res.status(400).json({
"ok", false,
"error", "Invalid product id"
});
return;
}
const bool deleted = delete_product(db, id);
if (!deleted)
{
res.status(404).json({
"ok", false,
"error", "Product not found"
});
return;
}
res.json({
"ok", true,
"deleted", true
});
});
}
int main()
{
try
{
auto db = vix::db::Database::sqlite("storage/app.db");
initialize_schema(db);
App app;
install_middleware(app);
register_routes(app, db);
app.run(8080);
return 0;
}
catch (const std::exception &e)
{
vix::print("sqlite api error:", e.what());
return 1;
}
}Build with SQLite support
This example uses the Vix database module with SQLite.
Build with:
vix build --with-sqliteFor a release build:
vix build --preset release --with-sqliteRun with:
vix run --with-sqliteIf you use CMake directly, make sure your project links Vix with SQLite support enabled.
Optional CMakeLists.txt
Create:
CMakeLists.txtAdd:
cmake_minimum_required(VERSION 3.20)
project(sqlite_api_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(vix CONFIG REQUIRED)
add_executable(sqlite_api_demo
sqlite_api.cpp
)
target_link_libraries(sqlite_api_demo
PRIVATE
vix::vix
)If your installed Vix package exposes another target name, use the target name from your local installation.
Run it
vix run --with-sqliteThe server listens on:
http://127.0.0.1:8080The SQLite database is created at:
storage/app.dbTest health
curl -i http://127.0.0.1:8080/api/healthExpected body:
{
"ok": true,
"service": "sqlite-api"
}List products
curl -i http://127.0.0.1:8080/api/productsExpected body shape:
{
"ok": true,
"products": []
}If products already exist, the array contains rows from SQLite.
Create a product
curl -i \
-X POST http://127.0.0.1:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Laptop","price":999.99,"available":true}'Expected status:
201 CreatedExpected body shape:
{
"ok": true,
"product": {
"id": 1,
"name": "Laptop",
"price": 999.99,
"available": true,
"created_at": "..."
}
}Get one product
curl -i http://127.0.0.1:8080/api/products/1Expected body shape:
{
"ok": true,
"product": {
"id": 1,
"name": "Laptop",
"price": 999.99,
"available": true,
"created_at": "..."
}
}Missing product:
curl -i http://127.0.0.1:8080/api/products/999Expected status:
404 Not FoundUpdate a product
curl -i \
-X PATCH http://127.0.0.1:8080/api/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"Laptop Pro","price":1299.99,"available":true}'Expected body shape:
{
"ok": true,
"product": {
"id": 1,
"name": "Laptop Pro",
"price": 1299.99,
"available": true,
"created_at": "..."
}
}Delete a product
curl -i \
-X DELETE http://127.0.0.1:8080/api/products/1Expected body:
{
"ok": true,
"deleted": true
}Request it again:
curl -i http://127.0.0.1:8080/api/products/1Expected status:
404 Not FoundTest validation
Missing name:
curl -i \
-X POST http://127.0.0.1:8080/api/products \
-H "Content-Type: application/json" \
-d '{"price":999.99}'Expected status:
422 Unprocessable EntityInvalid price:
curl -i \
-X POST http://127.0.0.1:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Broken","price":0}'Expected status:
422 Unprocessable EntityInvalid JSON:
curl -i \
-X POST http://127.0.0.1:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":}'Expected status:
400 Bad RequestWrong content type:
curl -i \
-X POST http://127.0.0.1:8080/api/products \
-H "Content-Type: text/plain" \
-d '{"name":"Laptop","price":999.99}'Expected status:
415 Unsupported Media TypeThe JSON parser middleware rejects invalid HTTP input before the route handler runs.
The route handler validates business rules.
What the database code does
The database opens with:
auto db = vix::db::Database::sqlite("storage/app.db");The schema is created with exec():
db.exec(
"CREATE TABLE IF NOT EXISTS products ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"name TEXT NOT NULL,"
"price REAL NOT NULL,"
"available INTEGER NOT NULL DEFAULT 1,"
"created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"
")"
);Rows are inserted with bind parameters:
db.exec(
"INSERT INTO products (name, price, available) "
"VALUES (?, ?, ?)",
name,
price,
available
);Rows are read with query():
auto rows = db.query(
"SELECT id, name, price, available, created_at "
"FROM products "
"ORDER BY id DESC"
);Then the result set is read forward:
while (rows->next())
{
const auto &row = rows->row();
auto id = row.getInt64(0);
auto name = row.getString(1);
auto price = row.getDouble(2);
}Column indexes start at 0.
They follow the order of the SELECT list.
Why bind parameters matter
Values are passed after the SQL string:
db.exec(
"DELETE FROM products WHERE id = ?",
id
);The first value binds to the first ?.
Do not concatenate user input into SQL.
Bad:
db.exec(
"DELETE FROM products WHERE id = " + req.param("id")
);Good:
db.exec(
"DELETE FROM products WHERE id = ?",
id
);Prepared statements keep SQL structure separate from values.
Why exec() and query() are separate
Use exec() for SQL that does not return rows:
CREATE TABLE
INSERT
UPDATE
DELETE
DROP TABLE
ALTER TABLEUse query() for SQL that returns rows:
SELECTExample:
db.exec(
"UPDATE products SET name = ? WHERE id = ?",
"Laptop Pro",
1
);and:
auto rows = db.query(
"SELECT id, name FROM products WHERE id = ?",
1
);This makes database code easy to read.
Middleware stack
The example installs:
app.use("/api", middleware::app::recovery_dev());
app.use("/api", middleware::app::request_id_dev());
app.use("/api", middleware::app::timing_dev());
app.use("/api", middleware::app::security_headers_dev());
app.use("/api", middleware::app::rate_limit_dev());
app.use("/api", middleware::app::body_limit_write_dev(1024 * 1024));
app.use("/api/products", middleware::app::json_strict_dev(
4096,
false,
true
));The order is intentional:
recovery
catches unhandled exceptions
request id
identifies requests
timing
measures request duration
security headers
hardens responses
rate limit
protects the API
body limit
rejects oversized request bodies
JSON parser
parses request bodies for product routesThe database code stays in helper functions.
The reusable HTTP behavior stays in middleware.
Why JSON parser is route-specific
The JSON parser is installed on:
app.use("/api/products", middleware::app::json_strict_dev(...));not globally on:
app.use("/api", middleware::app::json_strict_dev(...));That matters because:
GET /api/health
does not need a JSON body
GET /api/products
does not need a JSON body
POST /api/products
needs a JSON body
PATCH /api/products/{id}
needs a JSON bodyInstall parsers only where they make sense.
Inspect the database
Use the SQLite CLI:
sqlite3 storage/app.dbInside SQLite:
.tables
SELECT * FROM products;Exit:
.quitReset the database
Stop the server.
Remove the database files:
rm -f storage/app.db storage/app.db-wal storage/app.db-shmRun the server again:
vix run --with-sqliteThe schema will be recreated automatically.
Add WAL mode
For local APIs, SQLite WAL mode can be useful.
Add this after opening the database:
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA busy_timeout = 5000");
db.exec("PRAGMA foreign_keys = ON");Example:
auto db = vix::db::Database::sqlite("storage/app.db");
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA busy_timeout = 5000");
db.exec("PRAGMA foreign_keys = ON");
initialize_schema(db);These settings are useful for many SQLite applications:
journal_mode = WAL
improves read/write behavior for many local workloads
busy_timeout = 5000
waits briefly when the database is busy
foreign_keys = ON
enforces foreign key constraintsAdd a transaction later
This example uses one SQL statement per write.
For multi-step writes, use a transaction.
Example shape:
vix::db::Transaction tx(db.pool());
auto &conn = tx.conn();
auto insert = conn.prepare(
"INSERT INTO products (name, price, available) VALUES (?, ?, ?)"
);
insert->bind(1, "Laptop");
insert->bind(2, 999.99);
insert->bind(3, true);
insert->exec();
tx.commit();If commit() is not called, the transaction rolls back when it leaves scope.
Use transactions when several database operations must succeed together.
Use configuration later
For an example, this is fine:
auto db = vix::db::Database::sqlite("storage/app.db");For a real project, move the database path into configuration.
Example vix.json:
{
"name": "SQLiteApi",
"database": {
"engine": "sqlite",
"sqlite": {
"path": "storage/app.db"
},
"storage": "storage",
"migrations": "migrations"
}
}Example .env:
DATABASE_ENGINE=sqlite
DATABASE_SQLITE_PATH=storage/app.db
SERVER_PORT=8080Use vix.json for project shape.
Use .env for runtime values.
Production notes
This example is a good starting point, not a full production database layer.
For a production backend, add:
migrations
repositories
pagination
filtering
transactions for multi-step writes
authentication
authorization
structured logs
testsFor SQLite specifically:
keep writes short
avoid long write transactions
enable WAL when useful
set a busy timeout
store the database under storage/
do not commit runtime database filesA typical .gitignore should contain:
storage/*.db
storage/*.db-wal
storage/*.db-shmComplete test flow
Run:
vix run --with-sqliteHealth:
curl -i http://127.0.0.1:8080/api/healthCreate:
curl -i \
-X POST http://127.0.0.1:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Laptop","price":999.99,"available":true}'List:
curl -i http://127.0.0.1:8080/api/productsGet one:
curl -i http://127.0.0.1:8080/api/products/1Update:
curl -i \
-X PATCH http://127.0.0.1:8080/api/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"Laptop Pro","price":1299.99,"available":true}'Delete:
curl -i \
-X DELETE http://127.0.0.1:8080/api/products/1Reset:
rm -f storage/app.db storage/app.db-wal storage/app.db-shmSummary
A small SQLite-backed Vix API follows this shape:
open database
create schema
install middleware
register routes
use exec() for writes
use query() for reads
return JSON responsesThe core database entry point is:
auto db = vix::db::Database::sqlite("storage/app.db");Use exec() for writes:
db.exec(
"INSERT INTO products (name, price) VALUES (?, ?)",
name,
price
);Use query() for reads:
auto rows = db.query(
"SELECT id, name, price FROM products"
);Use Vix middleware to protect and prepare the API:
app.use("/api", middleware::app::recovery_dev());
app.use("/api", middleware::app::security_headers_dev());
app.use("/api", middleware::app::body_limit_write_dev(1024 * 1024));
app.use("/api/products", middleware::app::json_strict_dev(4096));This gives you a real Vix API with persistent SQLite storage and a clean modern C++ structure.