diff --git "a/lightbug.\360\237\224\245" "b/lightbug.\360\237\224\245" index ad27aacc..582e699a 100644 --- "a/lightbug.\360\237\224\245" +++ "b/lightbug.\360\237\224\245" @@ -1,6 +1,37 @@ from lightbug_http import * +from lightbug_http.middleware.helpers import Success +from sys import is_defined +from lightbug_http import * + +@value +struct HelloWorld(HTTPHandler): + fn handle(self, context: Context) -> HTTPResponse: + var name = context.params.find("username") + if name: + return Success("Hello!") + else: + return Success("Hello, World!") fn main() raises: - var server = SysServer() - var handler = Welcome() - server.listen_and_serve("0.0.0.0:8080", handler) + if not is_defined["TEST"](): + var router = RouterMiddleware() + router.add("GET", "/hello", HelloWorld()) + + var middleware = MiddlewareChain() + middleware.add(CompressionMiddleware()) + middleware.add(ErrorMiddleware()) + middleware.add(LoggerMiddleware()) + middleware.add(CorsMiddleware(allows_origin = "*")) + middleware.add(BasicAuthMiddleware("admin", "password")) + middleware.add(StaticMiddleware("static")) + middleware.add(router) + middleware.add(NotFoundMiddleware()) + + var server = SysServer() + server.listen_and_serve("0.0.0.0:8080", middleware) + else: + try: + run_tests() + print("Test suite passed") + except e: + print("Test suite failed: " + e.__str__()) diff --git a/lightbug_http/__init__.mojo b/lightbug_http/__init__.mojo index 7259a87c..e5c8ed44 100644 --- a/lightbug_http/__init__.mojo +++ b/lightbug_http/__init__.mojo @@ -2,6 +2,7 @@ from lightbug_http.http import HTTPRequest, HTTPResponse, OK from lightbug_http.service import HTTPService, Welcome from lightbug_http.sys.server import SysServer from lightbug_http.tests.run import run_tests +from lightbug_http.middleware import * trait DefaultConstructible: fn __init__(inout self) raises: diff --git a/lightbug_http/middleware.mojo b/lightbug_http/middleware.mojo new file mode 100644 index 00000000..fd10576f --- /dev/null +++ b/lightbug_http/middleware.mojo @@ -0,0 +1,172 @@ +from lightbug_http.http import HTTPRequest, HTTPResponse + +struct Context: + var request: Request + var params: Dict[String, AnyType] + + fn __init__(self, request: Request): + self.request = request + self.params = Dict[String, AnyType]() + +trait Middleware: + var next: Middleware + + fn call(self, context: Context) -> Response: + ... + +struct ErrorMiddleware(Middleware): + fn call(self, context: Context) -> Response: + try: + return next.call(context: context) + catch e: Exception: + return InternalServerError() + +struct LoggerMiddleware(Middleware): + fn call(self, context: Context) -> Response: + print("Request: \(context.request)") + return next.call(context: context) + +struct StaticMiddleware(Middleware): + var path: String + + fnt __init__(self, path: String): + self.path = path + + fn call(self, context: Context) -> Response: + if context.request.path == "/": + var file = File(path: path + "index.html") + else: + var file = File(path: path + context.request.path) + + if file.exists: + var html: String + with open(file, "r") as f: + html = f.read() + return OK(html.as_bytes(), "text/html") + else: + return next.call(context: context) + +struct CorsMiddleware(Middleware): + var allow_origin: String + + fn __init__(self, allow_origin: String): + self.allow_origin = allow_origin + + fn call(self, context: Context) -> Response: + if context.request.method == "OPTIONS": + var response = next.call(context: context) + response.headers["Access-Control-Allow-Origin"] = allow_origin + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + return response + + if context.request.origin == allow_origin: + return next.call(context: context) + else: + return Unauthorized() + +struct CompressionMiddleware(Middleware): + fn call(self, context: Context) -> Response: + var response = next.call(context: context) + response.body = compress(response.body) + return response + + fn compress(self, body: Bytes) -> Bytes: + #TODO: implement compression + return body + + +struct RouterMiddleware(Middleware): + var routes: Dict[String, Middleware] + + fn __init__(self): + self.routes = Dict[String, Middleware]() + + fn add(self, method: String, route: String, middleware: Middleware): + routes[method + ":" + route] = middleware + + fn call(self, context: Context) -> Response: + # TODO: create a more advanced router + var method = context.request.method + var route = context.request.path + if middleware = routes[method + ":" + route]: + return middleware.call(context: context) + else: + return next.call(context: context) + +struct BasicAuthMiddleware(Middleware): + var username: String + var password: String + + fn __init__(self, username: String, password: String): + self.username = username + self.password = password + + fn call(self, context: Context) -> Response: + var request = context.request + var auth = request.headers["Authorization"] + if auth == "Basic \(username):\(password)": + context.params["username"] = username + return next.call(context: context) + else: + return Unauthorized() + +# always add at the end of the middleware chain +struct NotFoundMiddleware(Middleware): + fn call(self, context: Context) -> Response: + return NotFound() + +struct MiddlewareChain(HttpService): + var middlewares: Array[Middleware] + + fn __init__(self): + self.middlewares = Array[Middleware]() + + fn add(self, middleware: Middleware): + if middlewares.count == 0: + middlewares.append(middleware) + else: + var last = middlewares[middlewares.count - 1] + last.next = middleware + middlewares.append(middleware) + + fn func(self, request: Request) -> Response: + self.add(NotFoundMiddleware()) + var context = Context(request: request, response: response) + return middlewares[0].call(context: context) + +fn OK(body: Bytes) -> HTTPResponse: + return OK(body, String("text/plain")) + +fn OK(body: Bytes, content_type: String) -> HTTPResponse: + return HTTPResponse( + ResponseHeader(True, 200, String("OK").as_bytes(), content_type.as_bytes()), + body, + ) + +fn NotFound(body: Bytes) -> HTTPResponse: + return NotFoundResponse(body, String("text/plain")) + +fn NotFound(body: Bytes, content_type: String) -> HTTPResponse: + return HTTPResponse( + ResponseHeader(True, 404, String("Not Found").as_bytes(), content_type.as_bytes()), + body, + ) + +fn InternalServerError(body: Bytes) -> HTTPResponse: + return InternalServerErrorResponse(body, String("text/plain")) + +fn InternalServerError(body: Bytes, content_type: String) -> HTTPResponse: + return HTTPResponse( + ResponseHeader(True, 500, String("Internal Server Error").as_bytes(), content_type.as_bytes()), + body, + ) + +fn Unauthorized(body: Bytes) -> HTTPResponse: + return UnauthorizedResponse(body, String("text/plain")) + +fn Unauthorized(body: Bytes, content_type: String) -> HTTPResponse: + return HTTPResponse( + ResponseHeader(True, 401, String("Unauthorized").as_bytes(), content_type.as_bytes()), + body, + ) diff --git a/lightbug_http/middleware/__init__.mojo b/lightbug_http/middleware/__init__.mojo new file mode 100644 index 00000000..a225d1e2 --- /dev/null +++ b/lightbug_http/middleware/__init__.mojo @@ -0,0 +1,18 @@ +from lightbug_http.middleware.helpers import Success +from lightbug_http.middleware.middleware import Context, Middleware, MiddlewareChain + +from lightbug_http.middleware.basicauth import BasicAuthMiddleware +from lightbug_http.middleware.compression import CompressionMiddleware +from lightbug_http.middleware.cors import CorsMiddleware +from lightbug_http.middleware.error import ErrorMiddleware +from lightbug_http.middleware.logger import LoggerMiddleware +from lightbug_http.middleware.notfound import NotFoundMiddleware +from lightbug_http.middleware.router import RouterMiddleware, HTTPHandler +from lightbug_http.middleware.static import StaticMiddleware + +# from lightbug_http.middleware.csrf import CsrfMiddleware +# from lightbug_http.middleware.session import SessionMiddleware +# from lightbug_http.middleware.websocket import WebSocketMiddleware +# from lightbug_http.middleware.cache import CacheMiddleware +# from lightbug_http.middleware.cookies import CookiesMiddleware +# from lightbug_http.middleware.session import SessionMiddleware diff --git a/lightbug_http/middleware/basicauth.mojo b/lightbug_http/middleware/basicauth.mojo new file mode 100644 index 00000000..0cb2ec1e --- /dev/null +++ b/lightbug_http/middleware/basicauth.mojo @@ -0,0 +1,23 @@ +from lightbug_http.middleware.helpers import Unauthorized + +## BasicAuth middleware requires basic authentication to access the route. +@value +struct BasicAuthMiddleware(Middleware): + var next: Middleware + var username: String + var password: String + + fn __init__(inout self, username: String, password: String): + self.username = username + self.password = password + + fn call(self, context: Context) -> HTTPResponse: + var request = context.request + #TODO: request object should have a way to get headers + # var auth = request.headers["Authorization"] + var auth = "Basic " + self.username + ":" + self.password + if auth == "Basic " + self.username + ":" + self.password: + context.params["username"] = username + return next.call(context) + else: + return Unauthorized("Requires Basic Authentication") diff --git a/lightbug_http/middleware/compression.mojo b/lightbug_http/middleware/compression.mojo new file mode 100644 index 00000000..ddbc016d --- /dev/null +++ b/lightbug_http/middleware/compression.mojo @@ -0,0 +1,18 @@ +from lightbug_http.io.bytes import bytes + +alias Bytes = List[Int8] + +@value +struct CompressionMiddleware(Middleware): + var next: Middleware + + fn call(self, context: Context) -> HTTPResponse: + var response = self.next.call(context) + response.body_raw = self.compress(response.body_raw) + return response + + # TODO: implement compression + fn compress(self, body: String) -> Bytes: + #TODO: implement compression + return bytes(body) + diff --git a/lightbug_http/middleware/cors.mojo b/lightbug_http/middleware/cors.mojo new file mode 100644 index 00000000..43e2fd03 --- /dev/null +++ b/lightbug_http/middleware/cors.mojo @@ -0,0 +1,28 @@ +from lightbug_http.middleware.helpers import Unauthorized +from lightbug_http.io.bytes import bytes, bytes_equal + +## CORS middleware adds the necessary headers to allow cross-origin requests. +@value +struct CorsMiddleware(Middleware): + var next: Middleware + var allow_origin: String + + fn __init__(inout self, allow_origin: String): + self.allow_origin = allow_origin + + fn call(self, context: Context) -> HTTPResponse: + if bytes_equal(context.request.header.method(), bytes("OPTIONS")): + var response = self.next.call(context) + # TODO: implement headers + # response.headers["Access-Control-Allow-Origin"] = self.allow_origin + # response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + # response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + return response + + # TODO: implement headers + # if context.request.headers["origin"] == self.allow_origin: + # return self.next.call(context) + # else: + # return Unauthorized("CORS not allowed") + + return self.next.call(context) diff --git a/lightbug_http/middleware/error.mojo b/lightbug_http/middleware/error.mojo new file mode 100644 index 00000000..4a9dede2 --- /dev/null +++ b/lightbug_http/middleware/error.mojo @@ -0,0 +1,15 @@ +from lightbug_http.middleware.helpers import InternalServerError + +## Error handler will catch any exceptions thrown by the other +## middleware and return a 500 response. +## It should be the first middleware in the chain. +@value +struct ErrorMiddleware(Middleware): + var next: Middleware + + fn call(inout self, context: Context) -> HTTPResponse: + try: + return self.next.call(context) + except e: + return InternalServerError(e) + diff --git a/lightbug_http/middleware/helpers.mojo b/lightbug_http/middleware/helpers.mojo new file mode 100644 index 00000000..6cba3da2 --- /dev/null +++ b/lightbug_http/middleware/helpers.mojo @@ -0,0 +1,42 @@ +from lightbug_http.http import HTTPRequest, HTTPResponse, ResponseHeader + +### Helper functions to create HTTP responses +fn Success(body: String) -> HTTPResponse: + return Success(body, String("text/plain")) + +fn Success(body: String, content_type: String) -> HTTPResponse: + return HTTPResponse( + ResponseHeader(True, 200, String("Success").as_bytes(), content_type.as_bytes()), + body.as_bytes(), + ) + +fn NotFound(body: String) -> HTTPResponse: + return NotFound(body, String("text/plain")) + +fn NotFound(body: String, content_type: String) -> HTTPResponse: + return HTTPResponse( + ResponseHeader(True, 404, String("Not Found").as_bytes(), content_type.as_bytes()), + body.as_bytes(), + ) + +fn InternalServerError(body: String) -> HTTPResponse: + return InternalServerError(body, String("text/plain")) + +fn InternalServerError(body: String, content_type: String) -> HTTPResponse: + return HTTPResponse( + ResponseHeader(True, 500, String("Internal Server Error").as_bytes(), content_type.as_bytes()), + body.as_bytes(), + ) + +fn Unauthorized(body: String) -> HTTPResponse: + return Unauthorized(body, String("text/plain")) + +fn Unauthorized(body: String, content_type: String) -> HTTPResponse: + var header = ResponseHeader(True, 401, String("Unauthorized").as_bytes(), content_type.as_bytes()) + # TODO: currently no way to set headers or cookies + # header.headers["WWW-Authenticate"] = "Basic realm=\"Login Required\"" + + return HTTPResponse( + header, + body.as_bytes(), + ) diff --git a/lightbug_http/middleware/logger.mojo b/lightbug_http/middleware/logger.mojo new file mode 100644 index 00000000..e5afc6c7 --- /dev/null +++ b/lightbug_http/middleware/logger.mojo @@ -0,0 +1,12 @@ +## Logger middleware logs the request to the console. +@value +struct LoggerMiddleware(Middleware): + var next: Middleware + + fn call(self, context: Context) -> HTTPResponse: + var request = context.request + #TODO: request is not printable + # print("Request: ", request) + var response = self.next.call(context) + print("Response:", response) + return response diff --git a/lightbug_http/middleware/middleware.mojo b/lightbug_http/middleware/middleware.mojo new file mode 100644 index 00000000..11f67539 --- /dev/null +++ b/lightbug_http/middleware/middleware.mojo @@ -0,0 +1,41 @@ +from lightbug_http.service import HTTPService +from lightbug_http.http import HTTPRequest, HTTPResponse +from lightbug_http.middleware import * + +## Context is a container for the request and response data. +## It is passed to each middleware in the chain. +## It also contains a dictionary of parameters that can be shared between middleware. +@value +struct Context[ParamType: CollectionElement]: + var request: HTTPRequest + var params: Dict[String, String] + + fn __init__(inout self, request: HTTPRequest): + self.request = request + self.params = Dict[String, String]() + + +## Middleware is an interface for processing HTTP requests. +## Each middleware in the chain can modify the request and response. +trait Middleware: + fn call(self, context: Context) -> HTTPResponse: + ... + +## MiddlewareChain is a chain of middleware that processes the request. +## The chain is a linked list of middleware objects. +@value +struct MiddlewareChain(HTTPService): + var root: Middleware + + fn add(self, middleware: Middleware): + if self.root == nil: + self.root = middleware + else: + var current = self.root + while current.next != nil: + current = current.next + current.next = middleware + + fn func(self, request: HTTPRequest) raises -> HTTPResponse: + var context = Context(request) + return self.root.call(context) diff --git a/lightbug_http/middleware/notfound.mojo b/lightbug_http/middleware/notfound.mojo new file mode 100644 index 00000000..48e8c538 --- /dev/null +++ b/lightbug_http/middleware/notfound.mojo @@ -0,0 +1,9 @@ +from lightbug_http.middleware.helpers import NotFound + +## NotFound middleware returns a 404 response if no other middleware handles the request. It is a leaf node and always add at the end of the middleware chain +@value +struct NotFoundMiddleware(Middleware): + var next: Middleware + + fn call(self, context: Context) -> HTTPResponse: + return NotFound("Not Found") diff --git a/lightbug_http/middleware/router.mojo b/lightbug_http/middleware/router.mojo new file mode 100644 index 00000000..53fc92ac --- /dev/null +++ b/lightbug_http/middleware/router.mojo @@ -0,0 +1,28 @@ +## HTTPHandler is an interface for handling HTTP requests in the RouterMiddleware. +## It is a leaf node in the middleware chain. +trait HTTPHandler(CollectionElement): + fn handle(self, context: Context) -> HTTPResponse: + ... + + +## Router middleware routes requests to different middleware based on the path. +@value +struct RouterMiddleware[HTTPHandlerType: HTTPHandler](Middleware): + var next: Middleware + var routes: Dict[String, HTTPHandlerType] + + fn __init__(inout self): + self.routes = Dict[String, HTTPHandlerType]() + + fn add(self, method: String, route: String, handler: HTTPHandlerType): + self.routes[method + ":" + route] = handler + + fn call(self, context: Context) -> HTTPResponse: + # TODO: create a more advanced router + var method = context.request.header.method() + var route = context.request.uri().path() + var handler = self.routes.find(method + ":" + route) + if handler: + return handler.value().handle(context) + else: + return next.call(context) diff --git a/lightbug_http/middleware/static.mojo b/lightbug_http/middleware/static.mojo new file mode 100644 index 00000000..4554223e --- /dev/null +++ b/lightbug_http/middleware/static.mojo @@ -0,0 +1,24 @@ +from lightbug_http.middleware.helpers import Success + +## Static middleware serves static files from a directory. +@value +struct StaticMiddleware(Middleware): + var next: Middleware + var path: String + + fn __init__(inout self, path: String): + self.path = path + + fn call(self, context: Context) -> HTTPResponse: + var path = context.request.uri().path() + if path.endswith("/"): + path = path + "index.html" + + try: + var html: String + with open(path, "r") as f: + html = f.read() + + return Success(html, "text/html") + except e: + return self.next.call(context) diff --git a/lightbug_http/service.mojo b/lightbug_http/service.mojo index 908feeab..016dbe53 100644 --- a/lightbug_http/service.mojo +++ b/lightbug_http/service.mojo @@ -5,7 +5,6 @@ trait HTTPService: fn func(self, req: HTTPRequest) raises -> HTTPResponse: ... - @value struct Printer(HTTPService): fn func(self, req: HTTPRequest) raises -> HTTPResponse: @@ -19,6 +18,9 @@ struct Printer(HTTPService): struct Welcome(HTTPService): fn func(self, req: HTTPRequest) raises -> HTTPResponse: var uri = req.uri() + var html: String + with open("static/index.html", "r") as f: + html = f.read() if uri.path() == "/": var html: Bytes diff --git a/static/index.html b/static/index.html new file mode 100644 index 00000000..89ebf9af --- /dev/null +++ b/static/index.html @@ -0,0 +1,70 @@ + + + + + Welcome to Lightbug! + + + + +
Welcome to Lightbug!
+
A Mojo HTTP framework with wings
+<<<<<<< HEAD +
To get started, edit lightbug.🔥
+ Lightbug Image +======= +
To get started, edit lightbug.iOS fire emoji
+ Lightbug Image +>>>>>>> 7ae8912 (feat: support middleware) + + + \ No newline at end of file diff --git a/static/lightbug_welcome.html b/static/lightbug_welcome.html deleted file mode 100644 index 7f2d71e8..00000000 --- a/static/lightbug_welcome.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - Welcome to Lightbug! - - - - -
Welcome to Lightbug!
-
A Mojo HTTP framework with wings
-
To get started, edit lightbug.🔥
- Lightbug Image - - - \ No newline at end of file