From b3e140214a47221f15898e48cccd154b3f3226ed Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Fri, 3 Mar 2023 20:10:35 +0100 Subject: [PATCH] feat(router): add a router plugin --- example/app.lua | 70 +++++++++ example/copas.lua | 91 +++++++++++- example/root/index.html | 12 +- rockspecs/pegasus-dev-1.rockspec | 1 + src/pegasus/plugins/router.lua | 247 +++++++++++++++++++++++++++++++ src/pegasus/request.lua | 2 +- 6 files changed, 416 insertions(+), 7 deletions(-) create mode 100644 src/pegasus/plugins/router.lua diff --git a/example/app.lua b/example/app.lua index 2856077..de46a35 100644 --- a/example/app.lua +++ b/example/app.lua @@ -11,8 +11,73 @@ local Pegasus = require 'pegasus' local Compress = require 'pegasus.plugins.compress' local Downloads = require 'pegasus.plugins.downloads' local Files = require 'pegasus.plugins.files' +local Router = require 'pegasus.plugins.router' +local json = require 'pegasus.json' -- local TLS = require 'pegasus.plugins.tls' + +-- example data for the "router" plugin +local routes do + local testData = { + Jane = { firstName = "Jane", lastName = "Doe", age = 25 }, + John = { firstName = "John", lastName = "Doe", age = 30 }, + } + + routes = { + -- router-level preFunction runs before the method prefunction and callback + preFunction = function(req, resp) + local stop = false + local headers = req:headers() + local accept = (headers.accept or "*/*"):lower() + if not accept:find("application/json", 1, true) and + not accept:find("application/*", 1, true) and + not accept:find("*/*", 1, true) then + + resp:writeDefaultErrorMessage(406, "This API only produces 'application/json'") + stop = true + end + return stop + end, + + ["/people"] = { + GET = function(req, resp) + resp:statusCode(200) + resp:addHeader("Content-Type", "application/json") + resp:write(json.encode(testData)) + end, + }, + + ["/people/{name}"] = { + -- path-level preFunction runs before the actual method callback + preFunction = function(req, resp) + local stop = false + local name = req.pathParameters.name + if not testData[name] then + local err = ("'%s' is an unknown person"):format(name) + resp:writeDefaultErrorMessage(404, err) + stop = true + end + return stop + end, + + -- callback per method + GET = function(req, resp) + resp:statusCode(200) + resp:addHeader("Content-Type", "application/json") + resp:write(json.encode(testData[req.pathParameters.name])) + end, + + -- postFunction runs after the actual method callback + postFunction = function(req, resp) + local stop = false + print("served " .. req.pathParameters.name .. "'s data") + return stop + end, + } + } +end + + local server = Pegasus:new({ port = '9090', plugins = { @@ -39,6 +104,11 @@ local server = Pegasus:new({ location = '/example/root/', }, + Router:new { + prefix = "/api/1v0/", + routes = routes, + }, + Compress:new(), } }) diff --git a/example/copas.lua b/example/copas.lua index 586e128..e50537e 100644 --- a/example/copas.lua +++ b/example/copas.lua @@ -12,6 +12,11 @@ local Handler = require 'pegasus.handler' local copas = require('copas') local socket = require('socket') local Downloads = require 'pegasus.plugins.downloads' +local Files = require 'pegasus.plugins.files' +local Router = require 'pegasus.plugins.router' +local Compress = require 'pegasus.plugins.compress' +local json = require 'pegasus.json' + --- Creates a new server within the Copas scheduler. -- @tparam table opts options table. @@ -25,7 +30,6 @@ local Downloads = require 'pegasus.plugins.downloads' -- @return the server-socket on success, or nil+err on failure local function newPegasusServer(opts) opts = opts or {} - assert(opts.location or opts.callback, "either 'location' or 'callback' must be provided") assert(opts.port, "option 'port' must be provided") local server_sock, err = socket.bind(opts.interface or '*', opts.port) @@ -36,6 +40,10 @@ local function newPegasusServer(opts) local server_ip, server_port = server_sock:getsockname() if not server_ip then return nil, "failed to get server socket name; "..tostring(server_port) + else + if server_ip == "0.0.0.0" then + server_ip = "localhost" + end end local hdlr = Handler:new(opts.callback, opts.location, opts.plugins) @@ -49,6 +57,68 @@ local function newPegasusServer(opts) end +-- example data for the "router" plugin +local routes do + local testData = { + Jane = { firstName = "Jane", lastName = "Doe", age = 25 }, + John = { firstName = "John", lastName = "Doe", age = 30 }, + } + + routes = { + -- router-level preFunction runs before the method prefunction and callback + preFunction = function(req, resp) + local stop = false + local headers = req:headers() + local accept = (headers.accept or "*/*"):lower() + if not accept:find("application/json", 1, true) and + not accept:find("application/*", 1, true) and + not accept:find("*/*", 1, true) then + + resp:writeDefaultErrorMessage(406, "This API only produces 'application/json'") + stop = true + end + return stop + end, + + ["/people"] = { + GET = function(req, resp) + resp:statusCode(200) + resp:addHeader("Content-Type", "application/json") + resp:write(json.encode(testData)) + end, + }, + + ["/people/{name}"] = { + -- path-level preFunction runs before the actual method callback + preFunction = function(req, resp) + local stop = false + local name = req.pathParameters.name + if not testData[name] then + local err = ("'%s' is an unknown person"):format(name) + resp:writeDefaultErrorMessage(404, err) + stop = true + end + return stop + end, + + -- callback per method + GET = function(req, resp) + resp:statusCode(200) + resp:addHeader("Content-Type", "application/json") + resp:write(json.encode(testData[req.pathParameters.name])) + end, + + -- postFunction runs after the actual method callback + postFunction = function(req, resp) + local stop = false + print("served " .. req.pathParameters.name .. "'s data") + return stop + end, + } + } +end + + -- Create http server assert(newPegasusServer{ @@ -80,14 +150,25 @@ assert(newPegasusServer{ }, sni = nil, }, - location = '/example/root/', - callback = nil, + plugins = { Downloads:new { - prefix = "downloads", + location = '/example/root/', + prefix = 'downloads', stripPrefix = true, }, - }, + + Files:new { + location = '/example/root/', + }, + + Router:new { + prefix = "/api/1v0/", + routes = routes, + }, + + Compress:new(), + } }) -- Start diff --git a/example/root/index.html b/example/root/index.html index f890903..6016277 100644 --- a/example/root/index.html +++ b/example/root/index.html @@ -17,5 +17,15 @@ Age: +

Try the following API calls for the Router plugin: +

+ To see the router features in effect +

- + diff --git a/rockspecs/pegasus-dev-1.rockspec b/rockspecs/pegasus-dev-1.rockspec index bba73cd..16aebcd 100644 --- a/rockspecs/pegasus-dev-1.rockspec +++ b/rockspecs/pegasus-dev-1.rockspec @@ -41,6 +41,7 @@ build = { ['pegasus.plugins.compress'] = 'src/pegasus/plugins/compress.lua', ['pegasus.plugins.downloads'] = 'src/pegasus/plugins/downloads.lua', ['pegasus.plugins.files'] = 'src/pegasus/plugins/files.lua', + ['pegasus.plugins.router'] = 'src/pegasus/plugins/router.lua', ['pegasus.plugins.tls'] = 'src/pegasus/plugins/tls.lua', } } diff --git a/src/pegasus/plugins/router.lua b/src/pegasus/plugins/router.lua new file mode 100644 index 0000000..8bf8b12 --- /dev/null +++ b/src/pegasus/plugins/router.lua @@ -0,0 +1,247 @@ +--- A plugin that routes requests based on path and method. +-- Supports path parameters. +-- +-- The `routes` table to configure the router is a hash-table where the keys are the path, and +-- the value is another hash-table. The second hash-table has the method as the key, and the +-- callbacks as the values. +-- Both hash-tables can have "`preFunction`" and "`postFunction`" entries, +-- which should have callbacks as values. +-- +-- There are 5 callbacks (called in this order); +-- +-- * the router `preFunction` callback is called first when there is a `prefix` match. It can +-- be used to do some validations, like path parameters, etc. It is defined on `router` level. +-- +-- * the path `preFunction` callback is called when there is a `path` match. It can +-- be used to do some validations, like path parameters, etc. It is defined on `path` level. +-- +-- * the `METHOD` (eg. `GET, `POST`, etc) this callback implements the specific method. It is defined +-- once for each supported method on the path. The special case is method "`*`" which is a catch-all. +-- The catch-all will be used for any method that doesn't have its own handler defined. +-- If omitted, the default catch-all will return a "405 Method Not Allowed" error. +-- +-- * the path `postFunction` is called after the `METHOD` callback. This one is defined on `path` level. +-- +-- * the router `postFunction` is called last. This one is defined on `router` level. +-- +-- The callbacks have the following function signature; `stop = function(request, response)`. +-- If `stop` is truthy, request handling is terminated, no further callbacks will be called. +-- +-- Path parameters can be defined in the path in curly braces; "`{variableName}`", and they will match +-- a single path segment. The values will be made available on the Request object as +-- `request.pathParameters.variableName`. +-- +-- The API sub-path (without the prefix) is available as on the Request object as `request.routerPath`. +-- +-- Route matching is based on a complete match (not prefix). And the order is based on the number +-- of path-parameters defined. Least number of parameters go first, such that static paths have +-- precedence over variables. +-- @usage +-- local routes = { +-- preFunction = function(req, resp) +-- local stop = false +-- -- this gets called before any path specific callback +-- +-- if some_error then +-- resp:writeDefaultErrorMessage(400) +-- stop = true +-- end +-- return stop +-- end, +-- +-- +-- ["/my/{accountNumber}/{param2}/endpoint"] = { -- define path parameters +-- +-- preFunction = function(req, resp) +-- local stop = false +-- -- this gets called before any method specific callback, +-- -- but after the path-preFunction +-- return stop +-- end, +-- +-- GET = function(req, resp) +-- local stop = false +-- -- this implements the main GET logic +-- return stop +-- end, +-- +-- POST = function(req, resp) +-- local stop = false +-- -- this implements the main POST logic +-- return stop +-- end, +-- +-- ["*"] = function(req, resp) +-- local stop = false +-- -- this implements the wildcard, will handle any method except for the +-- -- GET/POST ones defined above. +-- +-- -- If the wildcard is not defined, then a default one will be added which +-- -- only returns a "405 Method Not Allowed" error. +-- return stop +-- end, +-- +-- postFunction = function(req, resp) +-- local stop = false +-- -- this gets called before after the method specific (or wildcard) +-- -- callback. +-- +-- return stop +-- end, +-- }, +-- +-- ["/my/endpoint"] = function(req, resp) +-- local stop = false +-- -- this is a shortcut to create a wildcard-method, one callback +-- -- to handle any method for this path. Identical to: +-- -- ["/my/endpoint"] = { ["*"] = function(req, resp) ... end } +-- return stop +-- end, +-- +-- postFunction = function(req, resp) +-- local stop = false +-- -- this gets called last. +-- return stop +-- end, +-- } +-- +-- local router = Router:new { +-- prefix = "/api/1v0/", +-- routes = routes, +-- } + +local Router = {} +Router.__index = Router + +local noOpCallback = function() + return false +end + +local function methodNotAllowed(req, resp) + resp:writeDefaultErrorMessage(405) + return true -- "stop" +end + +-- Parse the routes table. +local function parseRoutes(self, routes, prefix) + local rts = {} + local routerPreFunction = routes.preFunction + local routerPostFunction = routes.postFunction + + for path, methods in pairs(routes) do + if path ~= "preFunction" and path ~= "postFunction" then + assert(path:sub(1,1) == "/", "paths must start with '/', got: " .. path) + + if type(methods) == "function" then + methods = { ["*"] = methods } -- turn a single-function-shortcut into a table + end + + methods["*"] = methods["*"] or methodNotAllowed + + local m = {} + for method, callback in pairs(methods) do + assert(type(callback) == "function", "expected callback to be a function, got: " .. type(callback)) + + if method ~= "preFunction" and method ~= "postFunction" then + assert(method == method:upper(), "expected method to be allcaps, got: " .. tostring(method)) + + if method ~= "*" then + m[method] = callback + else + -- a "catch all"; '*', so add metamethod to return the catch all + setmetatable(m, { + __index = function(self, key) + return callback + end + }) + end + end + end + + local params = {} + local pattern = path:gsub("{(%w+)}", function(name) + params[#params+1] = name + return "([^/]+)" + end) + pattern = "^" .. pattern .. "$" + + -- create and store the route + rts[#rts+1] = { + pattern = pattern, + params = params, + methods = m, + routerPreFunction = routerPreFunction or noOpCallback, + preFunction = methods.preFunction or noOpCallback, + postFunction = methods.postFunction or noOpCallback, + routerPostFunction = routerPostFunction or noOpCallback, + } + end + end + + -- sort by number of parameters in the path, least go first + table.sort(rts, function(a,b) return #a.params < #b.params end) + return rts +end + +--- Creates a new Router plugin instance. +-- @tparam options table the options table with the following fields; +-- @tparam[opt] options.prefix string the base path for all underlying routes. +-- @tparam options.routes table route definitions to be handled by this router plugin instance. +-- @return the new plugin +function Router:new(options) + options = options or {} + local plugin = {} + + local prefix = "/" .. (options.prefix or "") .. "/" + while prefix:find("//") do + prefix = prefix:gsub("//", "/") + end + plugin.prefix = prefix:sub(1, -2) -- drop trailing slash + + plugin.routes = parseRoutes(plugin, options.routes) + + setmetatable(plugin, Router) + return plugin +end + + + +function Router:newRequestResponse(request, response) + local stop = false + + local path = request:path() + if path:sub(1, #self.prefix) ~= self.prefix then + return stop + end + path = path:sub(#self.prefix + 1, -1) + + for _, route in ipairs(self.routes) do + local matches = { path:match(route.pattern) } + if matches[1] ~= nil then + -- we have a match + local p = {} + for i, paramName in ipairs(route.params) do + p[paramName] = matches[i] + end + + request.pathParameters = p + request.routerPath = path -- the request path without the prefix + + stop = route.routerPreFunction(request, response) + if stop then break end + stop = route.preFunction(request, response) + if stop then break end + stop = route.methods[request:method()](request, response) + if stop then break end + stop = route.postFunction(request, response) + if stop then break end + route.routerPostFunction(request, response) + stop = true + break + end + end + + return stop +end + +return Router diff --git a/src/pegasus/request.lua b/src/pegasus/request.lua index 0582934..b37f058 100644 --- a/src/pegasus/request.lua +++ b/src/pegasus/request.lua @@ -81,7 +81,7 @@ function Request:parseFirstLine() end self.response:skipBody(method == "HEAD") - print('Request for: ' .. path) + print('Request for: ' .. method .. " " .. path) local filename = '' local querystring = ''