# NSimpleWebApp A lightweight Go web framework with Jinja2 templates, Lua back-end scripts, and SQLite3 database support. ## Quick Start ```bash go build -o nsimplewebapp . ./nsimplewebapp # Server starts at http://localhost:8080 ``` All web content lives in the `web/` directory. Data files live in `data/`. ## Project Structure ``` web/ ├── index.jinja2 # Templates (.jinja2 extension) ├── _base.jinja2 # Internal template (underscore = not web-accessible) ├── app.css # Static files served directly ├── api/ │ └── hello.lua # Lua scripts for back-end logic └── contact/ ├── _contact.lua # Internal Lua module (require-only) ├── submit.lua # POST handler └── messages.lua # GET/DELETE API data/ └── contact.db # Database files (sandboxed, Lua-only access) ``` **URL Resolution** (in order): 1. `/` → `web/index.jinja2` 2. `.jinja2` extension → render as template 3. `.lua` extension → execute as script 4. Exact file match → serve static 5. Append `.jinja2` → render as template if it exists 6. Append `.lua` → execute as script if it exists **Hidden files/directories** (underscore prefix): Any path component starting with `_` is inaccessible via URL and returns 404. These are for internal-only modules and base templates that are meant to be reused via `extends` or `require()`. --- ## Jinja2 Templates Templates use [Gonja](https://github.com/nikolalohinski/gonja), a Jinja2-compatible Go engine. ### Template Context Each template receives these variables: | Variable | Description | |---|---| | `request.method` | HTTP method (`GET`, `POST`, etc.) | | `request.path` | Request path | | `request.query` | Query parameters as a dict | | `request.form` | POST form fields as a dict | | `request.headers` | Request headers (lowercase keys) | | `base_url` | The scheme + host (e.g. `http://localhost:8080`) | ### Template Inheritance Base templates are stored with an underscore prefix (e.g. `_base.jinja2`) to keep them from being served directly. Child templates extend them by path (without the `web/` prefix): ```jinja2 {# _base.jinja2 #} {% block title %}App{% endblock %} {% block content %}{% endblock %} {# index.jinja2 #} {% extends "_base.jinja2" %} {% block title %}Home{% endblock %} {% block content %}

Hello

{% endblock %} ``` --- ## Lua Scripts Lua scripts handle back-end logic. Each script defines one or more HTTP method handlers (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`). The framework calls the matching handler and passes a `req` table. ### Handler Signature ```lua function GET(req) -- Return: status_code, body, optional_headers return 200, "Hello World" end function POST(req) return 201, {id = 1, name = "created"}, {["Location"] = "/items/1"} end ``` **Return values**: 1. **status** (number) — HTTP status code. Defaults to 200 if omitted. 2. **body** (string, table, or nil) — String returned as-is. Tables are JSON-encoded automatically. 3. **headers** (table, optional) — Key-value pairs of header names to values. ### Request Object The `req` parameter provides access to the HTTP request: | Property/Method | Description | |---|---| | `req.path` | Request path string | | `req.method` | HTTP method string | | `req.body` | Raw request body string | | `req.query` | Query parameters table | | `req.headers` | Request headers table (lowercase keys) | | `req:json()` | Parse body as JSON → `(table, error)` | | `req:form()` | Parse body as form data → `(table, error)` | --- ## Available Lua Functions ### `json.encode(value)` / `json.decode(string)` Serialize/deserialize JSON. ```lua local data, err = json.decode(req.body) return 200, json.encode({status = "ok", data = data}) ``` Returns `(value, error)`. ### `os.date(format)` / `os.time()` - `os.date(format)` — Format current time. Default: `"%Y-%m-%d %H:%M:%S"`. Supports: `%Y`, `%y`, `%m`, `%d`, `%H`, `%M`, `%S`, `%p`, `%A`, `%a`, `%B`, `%b`, `%T`, `%c`, `%x`, `%X`. - `os.time()` — Current Unix timestamp. ```lua local ts = os.date("%Y-%m-%d") -- "2025-01-15" local now = os.time() -- 1705334400 ``` ### `fs.read(path)` / `fs.write(path, content)` / `fs.list(path)` / `fs.exists(path)` File operations restricted to the `data/` directory. All paths are relative to `data/`. ```lua -- Read a file local content, err = fs.read("config.txt") -- Write a file local ok, err = fs.write("output.txt", "Hello") -- List directory contents local files, err = fs.list("") -- empty string = data/ root -- Check existence if fs.exists("cache.json") then local data = fs.read("cache.json") end ``` **Sandbox**: All file access is confined to `data/`. Absolute paths and `..` traversal are blocked. ### `http.get(url)` / `http.post(url, body, content_type?)` Make HTTP requests to external services. ```lua -- GET request local status, body = http.get("https://api.example.com/data") -- POST request local status, body = http.post( "https://api.example.com/submit", json.encode({key = "value"}), "application/json" -- optional, defaults to application/json ) ``` Returns `(status_code, body_string)` or `(nil, error_string)`. ### `db.open(filename)` / `db.openMemory()` Open a SQLite3 database. File-based databases are stored in `data/`. ```lua -- File-based database (saved in data/myapp.db) local mydb, err = db.open("myapp.db") -- In-memory database (ephemeral) local mydb, err = db.openMemory() ``` Returns `(database_handle, error)`. #### Database Handle Methods | Method | Description | Returns | |---|---|---| | `mydb:exec(sql, ...)` | Execute SQL without results (INSERT, UPDATE, CREATE, etc.) | `(true, nil)` or `(nil, error)` | | `mydb:query(sql, ...)` | Query and return all matching rows | `({rows}, nil)` or `(nil, error)` | | `mydb:queryRow(sql, ...)` | Query and return the first row only | `({row}, nil)` or `(nil, error)` | | `mydb:close()` | Close the database connection | `(true, nil)` or `(nil, error)` | **Query results** are arrays of tables, where each table has column name keys: ```lua local rows, err = mydb:query("SELECT id, name, email FROM users WHERE active = ?", 1) -- rows = {{id=1, name="Alice", email="a@x.com"}, {id=2, name="Bob", email="b@x.com"}} local row, err = mydb:queryRow("SELECT COUNT(*) as total FROM users") -- row = {total = 42} local ok, err = mydb:exec("INSERT INTO users (name, email) VALUES (?, ?)", name, email) ``` --- ## Modules (`require()`) Share Lua code between scripts using `require()`. Modules are resolved relative to `web/` using dot notation: ``` require("contact._contact") → web/contact/_contact.lua require("utils.helpers") → web/utils/helpers.lua ``` **Module convention**: Modules must end with `_M = table` to export their API (standard `return` does not work in this sandbox): ```lua -- web/contact/_contact.lua (internal module, not web-accessible) local M = {} M.dbName = "contact.db" function M.openDatabase() local mydb, err = db.open(M.dbName) return mydb, err end function M.ensureTable(mydb) mydb:exec[[ CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL ) ]] end _M = M -- Export the module (not "return M") ``` Modules are cached after first load — `require()` returns the cached value on subsequent calls. --- ## Full Example: Contact Form **Template** (`contact.jinja2`): ```jinja2 {% extends "_base.jinja2" %} {% block content %}

Contact

{% if request.query.message is defined %}

Message sent!

{% endif %}
{% endblock %} ``` **Submit handler** (`contact/submit.lua`): ```lua local contact = require("contact._contact") function POST(req) local form, err = req:form() if err then return 400, {error = err} end local mydb, err = contact.openDatabase() if err then return 500, {error = err} end contact.ensureTable(mydb) mydb:exec("INSERT INTO messages (name, email, message, created_at) VALUES (?, ?, ?, ?)", form.name, form.email, form.message, os.date("%Y-%m-%d %H:%M:%S")) mydb:close() return 303, {redirect = "/contact?message=sent"}, {["Location"] = "/contact?message=sent"} end ``` **Messages API** (`contact/messages.lua`): ```lua local contact = require("contact._contact") function GET(req) local mydb, err = contact.openDatabase() if err then return 500, {error = err} end contact.ensureTable(mydb) local messages = mydb:query("SELECT * FROM messages ORDER BY id DESC LIMIT 50") mydb:close() return 200, {messages = messages} -- Tables auto-JSON-encoded end ```