Skip to content

Commit 83d8964

Browse files
authored
Merge pull request #2 from pessoa736/dev
feat: add CORS, pipeline, cache, tests and docs
2 parents 9023c61 + cadb9a1 commit 83d8964

29 files changed

Lines changed: 2270 additions & 348 deletions

.github/copilot-instructions.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Copilot Instructions for PudimServer
2+
3+
## Project Overview
4+
5+
PudimServer is a Lua HTTP server library distributed via LuaRocks. It provides a routing system where users create a server instance with `PudimServer:Create{}`, register routes with `:Routes(path, handler)`, and start with `:Run()`. Route handlers receive `(req, res)` and must return `res:response(status, body, headers)`.
6+
7+
## Dependencies & Runtime
8+
9+
- **Lua >= 5.4** (`.luarc.json` targets Lua 5.5)
10+
- **LuaRocks** for package management
11+
- Core deps: `luasocket`, `lua-cjson`, `loglua`, `luasec` (ssl)
12+
13+
Install dependencies:
14+
15+
```sh
16+
luarocks install luasocket --local
17+
luarocks install lua-cjson --local
18+
```
19+
20+
## Running the Test Server
21+
22+
```sh
23+
lua ./PudimServer/mysandbox/test.lua
24+
```
25+
26+
This starts a local server on `localhost:8080` serving HTML/CSS pages from `PudimServer/mysandbox/`.
27+
28+
## Running Tests
29+
30+
Unit tests use [busted](https://lunarmodules.github.io/busted/). Tests live in `spec/`.
31+
32+
```sh
33+
# Run all tests
34+
./lua_modules/bin/busted --no-auto-insulate spec/
35+
36+
# Run a single test file
37+
./lua_modules/bin/busted --no-auto-insulate spec/utils_spec.lua
38+
39+
# Run tests matching a pattern
40+
./lua_modules/bin/busted --no-auto-insulate spec/ --filter "ParseRequest"
41+
```
42+
43+
## Architecture
44+
45+
The library entry point is `PudimServer/init.lua`, which exports the `PudimServer` table (used as a class via metatables). Key modules:
46+
47+
- **`init.lua`** — Server lifecycle: `Create`, `Routes`, `Run`, `SetMiddlewares`, `RemoveMiddlewares`. `Run()` enters an infinite accept loop with middleware pipeline.
48+
- **`http.lua`** — HTTP parsing (`ParseRequest`) and response building (`response`). Tables passed as body are auto-encoded to JSON via `cjson`.
49+
- **`utils.lua`** — Shared utilities: file I/O, runtime type-checking system (`createInterface`/`verifyTypes`), and `loadMessageOnChange` for deduplicated logging.
50+
- **`cors.lua`** — CORS helpers: `createConfig`, `buildHeaders`, `preflightResponse`. Enabled via `Server:EnableCors(config)`.
51+
- **`pipeline.lua`** — Request/response pipeline (middleware chain at HTTP level). Handlers receive `(req, res, next)` and call `next()` to continue or return early to short-circuit.
52+
- **`cache.lua`** — In-memory response cache with TTL and eviction. Provides `Cache.new(config)` and `Cache.createPipelineHandler(cache)` for pipeline integration.
53+
- **`ServerChecks.lua`** — Network utilities (port-open check via TCP connect).
54+
55+
### Runtime Type System
56+
57+
The codebase uses a custom interface/contract system instead of relying solely on Lua's dynamic typing. Interfaces are created with `utils:createInterface{field = "type"}` and validated with `utils:verifyTypes(value, interface, logFn, shouldExit)`. This pattern is used extensively — new code should follow it.
58+
59+
### Middleware Pipeline
60+
61+
Middlewares are registered via `Server:SetMiddlewares{name = "...", Handler = function(client) ... end}` and run sequentially in `GetClient()` on each accepted connection. The `Run()` method currently auto-registers an SSL middleware.
62+
63+
### Logging
64+
65+
All logging uses the `loglua` library via the global `_G.log`. Modules use `log.inSection("name")` for scoped loggers and `utils:loadMessageOnChange()` to avoid duplicate log messages.
66+
67+
## Code Conventions
68+
69+
- **Indentation:** 2 spaces
70+
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes/modules
71+
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
72+
- **PR target:** Always target the `dev` branch
73+
- **LuaDoc annotations:** Use `---@class`, `---@field`, `---@type`, `---@param` annotations for type hints
74+
- **Comments:** Portuguese or English are both acceptable
75+
- **Globals:** `_G.log` and `_G.cjson` are initialized on first require and expected to be available globally
76+
77+
## LuaRocks Module Map
78+
79+
The rockspec at `rockspecs/pudimserver-0.1.0-1.rockspec` maps module names to files:
80+
81+
```
82+
PudimServer → PudimServer/init.lua
83+
PudimServer.http → PudimServer/http.lua
84+
PudimServer.utils → PudimServer/utils.lua
85+
PudimServer.cors → PudimServer/cors.lua
86+
PudimServer.pipeline → PudimServer/pipeline.lua
87+
PudimServer.cache → PudimServer/cache.lua
88+
PudimServer.ServerChecks → PudimServer/ServerChecks.lua
89+
```
90+
91+
New modules must be added to both the `modules` table in the rockspec and follow the `PudimServer/` directory structure.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ luac.out
4343

4444
*.crt
4545
*.key
46+
/luarocks
47+
/lua
48+
/lua_modules
49+
/.luarocks

.luarc.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"runtime.version": "Lua 5.5"
3+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
---@diagnostic disable: duplicate-doc-field
2+
3+
14
if not _G.log then _G.log = require("loglua") end
25
local socket = require("socket")
3-
local utils = require("PS.utils")
6+
local utils = require("PudimServer.utils")
47

58

69
--- interfaces

PudimServer/cache.lua

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
---@diagnostic disable: duplicate-doc-field
2+
3+
if not _G.log then _G.log = require("loglua") end
4+
local utils = require("PudimServer.utils")
5+
local socket = require("socket")
6+
7+
8+
--- interfaces
9+
10+
---@class CacheConfig
11+
---@field MaxSize number? Máximo de entradas no cache (default: 100)
12+
---@field DefaultTTL number? TTL padrão em segundos (default: 60)
13+
14+
local CacheConfigInter = utils:createInterface{
15+
MaxSize = {"number", "nil"},
16+
DefaultTTL = {"number", "nil"},
17+
}
18+
19+
20+
---@class CacheEntry
21+
---@field response string Resposta HTTP cacheada
22+
---@field expiresAt number Timestamp de expiração
23+
24+
25+
---------
26+
--- main
27+
28+
---@class Cache
29+
---@field _entries table<string, CacheEntry>
30+
---@field _maxSize number
31+
---@field _defaultTTL number
32+
local Cache = {}
33+
Cache.__index = Cache
34+
35+
36+
---@param config? CacheConfig
37+
---@return Cache
38+
function Cache.new(config)
39+
local CL = log.inSection("Cache")
40+
41+
config = config or {}
42+
utils:verifyTypes(config, CacheConfigInter, CL.error, true)
43+
44+
local self = setmetatable({}, Cache)
45+
self._entries = {}
46+
self._maxSize = config.MaxSize or 100
47+
self._defaultTTL = config.DefaultTTL or 60
48+
self._size = 0
49+
return self
50+
end
51+
52+
53+
---@param key string
54+
---@return string? response
55+
function Cache:get(key)
56+
local entry = self._entries[key]
57+
if not entry then return nil end
58+
59+
if socket.gettime() > entry.expiresAt then
60+
self._entries[key] = nil
61+
self._size = self._size - 1
62+
return nil
63+
end
64+
65+
return entry.response
66+
end
67+
68+
69+
---@param key string
70+
---@param response string
71+
---@param ttl? number TTL em segundos (usa default se nil)
72+
function Cache:set(key, response, ttl)
73+
if not self._entries[key] then
74+
-- Evict oldest if full
75+
if self._size >= self._maxSize then
76+
self:_evict()
77+
end
78+
self._size = self._size + 1
79+
end
80+
81+
self._entries[key] = {
82+
response = response,
83+
expiresAt = socket.gettime() + (ttl or self._defaultTTL)
84+
}
85+
end
86+
87+
88+
---@param key string
89+
function Cache:invalidate(key)
90+
if self._entries[key] then
91+
self._entries[key] = nil
92+
self._size = self._size - 1
93+
end
94+
end
95+
96+
97+
function Cache:clear()
98+
self._entries = {}
99+
self._size = 0
100+
end
101+
102+
103+
function Cache:_evict()
104+
local oldestKey = nil
105+
local oldestTime = math.huge
106+
107+
for key, entry in pairs(self._entries) do
108+
if entry.expiresAt < oldestTime then
109+
oldestTime = entry.expiresAt
110+
oldestKey = key
111+
end
112+
end
113+
114+
if oldestKey then
115+
self._entries[oldestKey] = nil
116+
self._size = self._size - 1
117+
end
118+
end
119+
120+
121+
---@param cacheInstance Cache
122+
---@param ttl? number TTL customizado
123+
---@return PipelineEntry
124+
function Cache.createPipelineHandler(cacheInstance, ttl)
125+
return {
126+
name = "cache",
127+
Handler = function(req, res, next)
128+
-- Só cacheia GET requests
129+
if req.method ~= "GET" then
130+
return next()
131+
end
132+
133+
local key = req.method .. ":" .. req.path
134+
local cached = cacheInstance:get(key)
135+
136+
if cached then
137+
log.inSection("Cache").debug("cache hit: " .. key)
138+
return cached
139+
end
140+
141+
local response = next()
142+
143+
if response then
144+
cacheInstance:set(key, response, ttl)
145+
log.inSection("Cache").debug("cache set: " .. key)
146+
end
147+
148+
return response
149+
end
150+
}
151+
end
152+
153+
154+
return Cache

PudimServer/cors.lua

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---@diagnostic disable: duplicate-doc-field
2+
3+
if not _G.log then _G.log = require("loglua") end
4+
local utils = require("PudimServer.utils")
5+
6+
7+
--- interfaces
8+
9+
---@class CorsConfig
10+
---@field AllowOrigins string|string[]? Origins permitidas (default: "*")
11+
---@field AllowMethods string|string[]? Métodos permitidos (default: "GET, POST, PUT, DELETE, PATCH, OPTIONS")
12+
---@field AllowHeaders string|string[]? Headers permitidos (default: "Content-Type, Authorization")
13+
---@field ExposeHeaders string|string[]? Headers expostos ao browser
14+
---@field AllowCredentials boolean? Permitir credentials (default: false)
15+
---@field MaxAge number? Tempo em segundos do cache preflight (default: 86400)
16+
17+
local CorsConfigInter = utils:createInterface{
18+
AllowOrigins = {"string", "table", "nil"},
19+
AllowMethods = {"string", "table", "nil"},
20+
AllowHeaders = {"string", "table", "nil"},
21+
ExposeHeaders = {"string", "table", "nil"},
22+
AllowCredentials = {"boolean", "nil"},
23+
MaxAge = {"number", "nil"},
24+
}
25+
26+
27+
---------
28+
--- main
29+
30+
---@type table
31+
local cors = {}
32+
33+
34+
---@param value string|string[]|nil
35+
---@param default string
36+
---@return string
37+
local function toHeaderValue(value, default)
38+
if not value then return default end
39+
if type(value) == "table" then
40+
return table.concat(value, ", ")
41+
end
42+
return value
43+
end
44+
45+
46+
---@param config? CorsConfig
47+
---@return table corsHeaders Headers CORS resolvidos
48+
---@return CorsConfig resolvedConfig Config com defaults aplicados
49+
function cors.createConfig(config)
50+
local CL = log.inSection("CORS")
51+
52+
config = config or {}
53+
utils:verifyTypes(config, CorsConfigInter, CL.error, true)
54+
55+
local resolved = {
56+
AllowOrigins = toHeaderValue(config.AllowOrigins, "*"),
57+
AllowMethods = toHeaderValue(config.AllowMethods, "GET, POST, PUT, DELETE, PATCH, OPTIONS"),
58+
AllowHeaders = toHeaderValue(config.AllowHeaders, "Content-Type, Authorization"),
59+
ExposeHeaders = toHeaderValue(config.ExposeHeaders, ""),
60+
AllowCredentials = config.AllowCredentials or false,
61+
MaxAge = config.MaxAge or 86400,
62+
}
63+
64+
return resolved
65+
end
66+
67+
68+
---@param config table Config resolvida do CORS
69+
---@param requestOrigin string? Origin do request
70+
---@return table headers Headers CORS para adicionar na resposta
71+
function cors.buildHeaders(config, requestOrigin)
72+
local headers = {}
73+
74+
headers["Access-Control-Allow-Origin"] = config.AllowOrigins
75+
headers["Access-Control-Allow-Methods"] = config.AllowMethods
76+
headers["Access-Control-Allow-Headers"] = config.AllowHeaders
77+
78+
if config.ExposeHeaders ~= "" then
79+
headers["Access-Control-Expose-Headers"] = config.ExposeHeaders
80+
end
81+
82+
if config.AllowCredentials then
83+
headers["Access-Control-Allow-Credentials"] = "true"
84+
end
85+
86+
headers["Access-Control-Max-Age"] = tostring(config.MaxAge)
87+
88+
return headers
89+
end
90+
91+
92+
---@param config table Config resolvida do CORS
93+
---@return string Resposta HTTP para preflight
94+
function cors.preflightResponse(config)
95+
local http = require("PudimServer.http")
96+
local headers = cors.buildHeaders(config)
97+
headers["Content-Length"] = "0"
98+
return http:response(204, "", headers)
99+
end
100+
101+
102+
return cors

0 commit comments

Comments
 (0)