Vix.cpp v2.6.0 is here Read the blog
Skip to content

SQLite API

This example shows how to build a small Vix JSON API backed by SQLite.

It uses the Vix database module:

cpp
#include <vix/db.hpp>
1

The example uses SQLite because it does not require a separate database server.

You will build:

txt
GET    /api/health
GET    /api/products
GET    /api/products/{id}
POST   /api/products
PATCH  /api/products/{id}
DELETE /api/products/{id}
1
2
3
4
5
6

The database file is stored at:

txt
storage/app.db
1

Why 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:

txt
a database server
a network connection
a database daemon
a separate setup step
1
2
3
4

For 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:

txt
sqlite_api_demo/
├── sqlite_api.cpp
└── storage/
1
2
3

Create the directory:

bash
mkdir -p sqlite_api_demo/storage
cd sqlite_api_demo
touch sqlite_api.cpp
1
2
3

Source

Open:

txt
sqlite_api.cpp
1

Add:

cpp
#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;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396

Build with SQLite support

This example uses the Vix database module with SQLite.

Build with:

bash
vix build --with-sqlite
1

For a release build:

bash
vix build --preset release --with-sqlite
1

Run with:

bash
vix run --with-sqlite
1

If you use CMake directly, make sure your project links Vix with SQLite support enabled.

Optional CMakeLists.txt

Create:

txt
CMakeLists.txt
1

Add:

cmake
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
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

If your installed Vix package exposes another target name, use the target name from your local installation.

Run it

bash
vix run --with-sqlite
1

The server listens on:

txt
http://127.0.0.1:8080
1

The SQLite database is created at:

txt
storage/app.db
1

Test health

bash
curl -i http://127.0.0.1:8080/api/health
1

Expected body:

json
{
  "ok": true,
  "service": "sqlite-api"
}
1
2
3
4

List products

bash
curl -i http://127.0.0.1:8080/api/products
1

Expected body shape:

json
{
  "ok": true,
  "products": []
}
1
2
3
4

If products already exist, the array contains rows from SQLite.

Create a product

bash
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}'
1
2
3
4

Expected status:

txt
201 Created
1

Expected body shape:

json
{
  "ok": true,
  "product": {
    "id": 1,
    "name": "Laptop",
    "price": 999.99,
    "available": true,
    "created_at": "..."
  }
}
1
2
3
4
5
6
7
8
9
10

Get one product

bash
curl -i http://127.0.0.1:8080/api/products/1
1

Expected body shape:

json
{
  "ok": true,
  "product": {
    "id": 1,
    "name": "Laptop",
    "price": 999.99,
    "available": true,
    "created_at": "..."
  }
}
1
2
3
4
5
6
7
8
9
10

Missing product:

bash
curl -i http://127.0.0.1:8080/api/products/999
1

Expected status:

txt
404 Not Found
1

Update a product

bash
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}'
1
2
3
4

Expected body shape:

json
{
  "ok": true,
  "product": {
    "id": 1,
    "name": "Laptop Pro",
    "price": 1299.99,
    "available": true,
    "created_at": "..."
  }
}
1
2
3
4
5
6
7
8
9
10

Delete a product

bash
curl -i \
  -X DELETE http://127.0.0.1:8080/api/products/1
1
2

Expected body:

json
{
  "ok": true,
  "deleted": true
}
1
2
3
4

Request it again:

bash
curl -i http://127.0.0.1:8080/api/products/1
1

Expected status:

txt
404 Not Found
1

Test validation

Missing name:

bash
curl -i \
  -X POST http://127.0.0.1:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"price":999.99}'
1
2
3
4

Expected status:

txt
422 Unprocessable Entity
1

Invalid price:

bash
curl -i \
  -X POST http://127.0.0.1:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Broken","price":0}'
1
2
3
4

Expected status:

txt
422 Unprocessable Entity
1

Invalid JSON:

bash
curl -i \
  -X POST http://127.0.0.1:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":}'
1
2
3
4

Expected status:

txt
400 Bad Request
1

Wrong content type:

bash
curl -i \
  -X POST http://127.0.0.1:8080/api/products \
  -H "Content-Type: text/plain" \
  -d '{"name":"Laptop","price":999.99}'
1
2
3
4

Expected status:

txt
415 Unsupported Media Type
1

The 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:

cpp
auto db = vix::db::Database::sqlite("storage/app.db");
1

The schema is created with exec():

cpp
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"
  ")"
);
1
2
3
4
5
6
7
8
9

Rows are inserted with bind parameters:

cpp
db.exec(
  "INSERT INTO products (name, price, available) "
  "VALUES (?, ?, ?)",
  name,
  price,
  available
);
1
2
3
4
5
6
7

Rows are read with query():

cpp
auto rows = db.query(
  "SELECT id, name, price, available, created_at "
  "FROM products "
  "ORDER BY id DESC"
);
1
2
3
4
5

Then the result set is read forward:

cpp
while (rows->next())
{
  const auto &row = rows->row();

  auto id = row.getInt64(0);
  auto name = row.getString(1);
  auto price = row.getDouble(2);
}
1
2
3
4
5
6
7
8

Column indexes start at 0.

They follow the order of the SELECT list.

Why bind parameters matter

Values are passed after the SQL string:

cpp
db.exec(
  "DELETE FROM products WHERE id = ?",
  id
);
1
2
3
4

The first value binds to the first ?.

Do not concatenate user input into SQL.

Bad:

cpp
db.exec(
  "DELETE FROM products WHERE id = " + req.param("id")
);
1
2
3

Good:

cpp
db.exec(
  "DELETE FROM products WHERE id = ?",
  id
);
1
2
3
4

Prepared statements keep SQL structure separate from values.

Why exec() and query() are separate

Use exec() for SQL that does not return rows:

txt
CREATE TABLE
INSERT
UPDATE
DELETE
DROP TABLE
ALTER TABLE
1
2
3
4
5
6

Use query() for SQL that returns rows:

txt
SELECT
1

Example:

cpp
db.exec(
  "UPDATE products SET name = ? WHERE id = ?",
  "Laptop Pro",
  1
);
1
2
3
4
5

and:

cpp
auto rows = db.query(
  "SELECT id, name FROM products WHERE id = ?",
  1
);
1
2
3
4

This makes database code easy to read.

Middleware stack

The example installs:

cpp
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
));
1
2
3
4
5
6
7
8
9
10
11
12

The order is intentional:

txt
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 routes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

The 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:

cpp
app.use("/api/products", middleware::app::json_strict_dev(...));
1

not globally on:

cpp
app.use("/api", middleware::app::json_strict_dev(...));
1

That matters because:

txt
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 body
1
2
3
4
5
6
7
8
9
10
11

Install parsers only where they make sense.

Inspect the database

Use the SQLite CLI:

bash
sqlite3 storage/app.db
1

Inside SQLite:

sql
.tables
SELECT * FROM products;
1
2

Exit:

txt
.quit
1

Reset the database

Stop the server.

Remove the database files:

bash
rm -f storage/app.db storage/app.db-wal storage/app.db-shm
1

Run the server again:

bash
vix run --with-sqlite
1

The schema will be recreated automatically.

Add WAL mode

For local APIs, SQLite WAL mode can be useful.

Add this after opening the database:

cpp
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA busy_timeout = 5000");
db.exec("PRAGMA foreign_keys = ON");
1
2
3

Example:

cpp
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);
1
2
3
4
5
6
7

These settings are useful for many SQLite applications:

txt
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 constraints
1
2
3
4
5
6
7
8

Add a transaction later

This example uses one SQL statement per write.

For multi-step writes, use a transaction.

Example shape:

cpp
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();
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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:

cpp
auto db = vix::db::Database::sqlite("storage/app.db");
1

For a real project, move the database path into configuration.

Example vix.json:

json
{
  "name": "SQLiteApi",
  "database": {
    "engine": "sqlite",
    "sqlite": {
      "path": "storage/app.db"
    },
    "storage": "storage",
    "migrations": "migrations"
  }
}
1
2
3
4
5
6
7
8
9
10
11

Example .env:

dotenv
DATABASE_ENGINE=sqlite
DATABASE_SQLITE_PATH=storage/app.db
SERVER_PORT=8080
1
2
3

Use 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:

txt
migrations
repositories
pagination
filtering
transactions for multi-step writes
authentication
authorization
structured logs
tests
1
2
3
4
5
6
7
8
9

For SQLite specifically:

txt
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 files
1
2
3
4
5
6

A typical .gitignore should contain:

txt
storage/*.db
storage/*.db-wal
storage/*.db-shm
1
2
3

Complete test flow

Run:

bash
vix run --with-sqlite
1

Health:

bash
curl -i http://127.0.0.1:8080/api/health
1

Create:

bash
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}'
1
2
3
4

List:

bash
curl -i http://127.0.0.1:8080/api/products
1

Get one:

bash
curl -i http://127.0.0.1:8080/api/products/1
1

Update:

bash
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}'
1
2
3
4

Delete:

bash
curl -i \
  -X DELETE http://127.0.0.1:8080/api/products/1
1
2

Reset:

bash
rm -f storage/app.db storage/app.db-wal storage/app.db-shm
1

Summary

A small SQLite-backed Vix API follows this shape:

txt
open database
create schema
install middleware
register routes
use exec() for writes
use query() for reads
return JSON responses
1
2
3
4
5
6
7

The core database entry point is:

cpp
auto db = vix::db::Database::sqlite("storage/app.db");
1

Use exec() for writes:

cpp
db.exec(
  "INSERT INTO products (name, price) VALUES (?, ?)",
  name,
  price
);
1
2
3
4
5

Use query() for reads:

cpp
auto rows = db.query(
  "SELECT id, name, price FROM products"
);
1
2
3

Use Vix middleware to protect and prepare the API:

cpp
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));
1
2
3
4

This gives you a real Vix API with persistent SQLite storage and a clean modern C++ structure.

Released under the MIT License.

Morty Proxy This is a proxified and sanitized view of the page, visit original site.