A middleware-based HTTP framework for Motoko on the Internet Computer.
Liminal is a flexible HTTP framework designed to make building web applications with Motoko simpler and more maintainable. It provides a middleware pipeline architecture, built-in routing capabilities, and a variety of helper modules for common web development tasks.
Key features:
- 🔄 Middleware: Compose your application using reusable middleware components
- 🛣️ Routing: Powerful route matching with parameter extraction and group support
- 🔒 CORS Support: Configurable Cross-Origin Resource Sharing
- 🔐 CSP Support: Content Security Policy configuration
- 📦 Asset Canister Integration: Simplified interface with Internet Computer's certified assets
- 🔑 JWT Authentication: Built-in JWT parsing and validation
- 🚀 Compression: Automatic response compression for performance
- ⏱️ Rate Limiting: Protect your APIs from abuse
- 🛡️ Authentication: Configurable authentication requirements
- 🔀 Content Negotiation: Automatically convert data to JSON, CBOR, XML based on Accept header
- 📤 File Uploads: Parse and process multipart/form-data for handling file uploads (limited to 2MB)
- 📝 Logging: Built-in logging system with configurable levels and custom logger support
- 🔐 OAuth Authentication: Built-in OAuth 2.0 support with PKCE for Google, GitHub, and custom providers
mops add liminalTo setup MOPS package manager, follow the instructions from the MOPS Site
Liminal uses a middleware pipeline pattern where each middleware component processes requests as they flow down the pipeline, and then processes responses as they flow back up in reverse order. This creates a "sandwich" effect where the first middleware to see a request is the last to process the response.
Request ──┐ ┌─> Response
│ |
▼ │
┌─────────────┐
- Decompresses │ Compression │ - Compresses
request │ Middleware │ response
└─────────────┘
│ ▲
▼ │
┌─────────────┐
- Parses JWT │ JWT │ - Ignores
- Sets identity │ Middleware │ response
└─────────────┘
│ ▲
▼ │
┌─────────────┐
- Matches url │ API Router │ - Returns API
to function │ Middleware │ response
└─────────────┘
Request Flow (Down): The HTTP request starts at the first middleware and flows down through each middleware in the order they were defined in your middleware array.
Response Generation: Any middleware in the pipeline can choose to generate a response and stop the request flow. When this happens, the response immediately begins flowing back up through only the middleware that have already processed the request, bypassing any remaining middleware further down the pipeline.
Response Flow (Up): The response then flows back up through the middleware pipeline in reverse order, allowing each middleware to modify or enhance the response.
In the Internet Computer, all HTTP requests start as Query calls (fast, read-only). If a middleware needs to modify state or make async calls, it can upgrade the request to an Update call (slower, can modify state). When this happens, the entire request restarts from the beginning with the same middleware pipeline.
Query Flow Update Flow
Request ──┐ ┌──────► Request ──┐ ┌─► Response
│ │ │ │
▼ │ ▼ │
┌─────────────┐ │ ┌─────────────┐
│ Compression │ │ │ Compression │
│ Middleware │ │ │ Middleware │
└─────────────┘ │ └─────────────┘
│ │ │ ▲
▼ │ ▼ │
┌─────────────┐ │ ┌─────────────┐
│ JWT │ ──┘ │ JWT │
│ Middleware │ Upgrade │ Middleware │
└─────────────┘ └─────────────┘
│ ▲
▼ │
┌─────────────┐ ┌─────────────┐
│ API Router │ │ API Router │
│ Middleware │ │ Middleware │
└─────────────┘ └─────────────┘
Query Processing: Request flows down through middleware as Query calls (fast path)
Upgrade Decision: Any middleware can decide it needs to upgrade (e.g., needs to modify state, make async calls)
Request Restart: When upgraded, the entire request restarts from the beginning as an Update call and go through each middleware again
Here's a minimal example to get started:
importLiminal"mo:liminal";
importRoute"mo:liminal/Route";
importRouter"mo:liminal/Router";
importRouteContext"mo:liminal/RouteContext";
importRouterMiddleware"mo:liminal/Middleware/Router";
importCORSMiddleware"mo:liminal/Middleware/CORS";
actor {
// Define your routeslet routerConfig = {
prefix = ?"/api";
identityRequirement =null;
routes = [
Router.get(
"/hello/{name}",
#query_(func(context : RouteContext.RouteContext) : Route.HttpResponse {
let name = context.getRouteParam("name");
context.buildResponse(#ok, #text("Hello, " # name # "!"));
})
)
]
};
// Create the HTTP App with middlewarelet app =Liminal.App({
middleware = [
// Order matters// First middleware will be called FIRST with the HTTP request// and LAST with handling the HTTP responseCORSMiddleware.default(),
RouterMiddleware.new(routerConfig),
];
errorSerializer =Liminal.defaultJsonErrorSerializer;
candidRepresentationNegotiator =Liminal.defaultCandidRepresentationNegotiator;
logger =Liminal.buildDebugLogger(#info);
urlNormalization = {
pathIsCaseSensitive =false;
preserveTrailingSlash =false;
queryKeysAreCaseSensitive =false;
removeEmptyPathSegments =true;
resolvePathDotSegments =true;
usernameIsCaseSensitive =false;
};
});
// Expose standard HTTP interfacepublicqueryfunc http_request(request : Liminal.RawQueryHttpRequest) : asyncLiminal.RawQueryHttpResponse {
app.http_request(request)
};
publicfunc http_request_update(request : Liminal.RawUpdateHttpRequest) : asyncLiminal.RawUpdateHttpResponse {
await* app.http_request_update(request)
};
}Here's a more comprehensive example demonstrating multiple middleware components:
importLiminal"mo:liminal";
importRoute"mo:liminal/Route";
importRouter"mo:liminal/Router";
importRouteContext"mo:liminal/RouteContext";
importRouterMiddleware"mo:liminal/Middleware/Router";
importCORSMiddleware"mo:liminal/Middleware/CORS";
importJWTMiddleware"mo:liminal/Middleware/JWT";
importCompressionMiddleware"mo:liminal/Middleware/Compression";
importCSPMiddleware"mo:liminal/Middleware/CSP";
importAssetsMiddleware"mo:liminal/Middleware/Assets";
importSessionMiddleware"mo:liminal/Middleware/Session";
importHttpAssets"mo:http-assets";
actor {
// Define your routeslet routerConfig = {
prefix = ?"/api";
identityRequirement =null;
routes = [
Router.get(
"/public",
#query_(func(context : RouteContext.RouteContext) : Route.HttpResponse {
context.buildResponse(#ok, #text("Public endpoint"))
})
),
Router.groupWithAuthorization(
"/secure",
[
Router.get(
"/profile",
#query_(func(context : RouteContext.RouteContext) : Route.HttpResponse {
context.buildResponse(#ok, #text("Secure profile endpoint"))
})
)
],
#authenticated
)
]
};
];
// Initialize asset storelet canisterId =Principal.fromActor(self);
let assetStableData =HttpAssets.init_stable_store(canisterId, initializer)
|>HttpAssets.upgrade_stable_store(_);
let assetStore =HttpAssets.Assets(assetStableData, ?setPermissions);
// Create the HTTP App with middlewarelet app =Liminal.App({
middleware = [
// Order matters - middleware are executed in this order for requests// and in reverse order for responsesCompressionMiddleware.default(),
CORSMiddleware.default(),
SessionMiddleware.inMemoryDefault(),
JWTMiddleware.new({
locations =JWTMiddleware.defaultLocations;
validation = {
audience = #skip;
issuer = #skip;
signature = #skip;
notBefore =false;
expiration =false;
};
}),
RouterMiddleware.new(routerConfig),
CSPMiddleware.default(),
AssetsMiddleware.new({
store = assetStore;
}),
];
errorSerializer =Liminal.defaultJsonErrorSerializer;
candidRepresentationNegotiator =Liminal.defaultCandidRepresentationNegotiator;
logger =Liminal.buildDebugLogger(#info);
urlNormalization = {
pathIsCaseSensitive =false;
preserveTrailingSlash =false;
queryKeysAreCaseSensitive =false;
removeEmptyPathSegments =true;
resolvePathDotSegments =true;
usernameIsCaseSensitive =false;
};
});
// Expose standard HTTP interfacepublicqueryfunc http_request(request : Liminal.RawQueryHttpRequest) : asyncLiminal.RawQueryHttpResponse {
app.http_request(request)
};
publicfunc http_request_update(request : Liminal.RawUpdateHttpRequest) : asyncLiminal.RawUpdateHttpResponse {
await* app.http_request_update(request)
};
}Middleware are components that process HTTP requests and responses in a pipeline. Each middleware can:
- Handle the request and produce a response
- Pass the request to the next middleware in the pipeline
- Modify the request before passing it on
- Modify the response after the next middleware processes it
importApp"mo:liminal/App";
importHttpContext"mo:liminal/HttpContext";
importHttpMethod"mo:liminal/HttpMethod";
// Example of a simple logging middlewarepublicfunc createLoggingMiddleware() : App.Middleware {
{
handleQuery =func(context : HttpContext.HttpContext, next : App.Next) : App.QueryResult {
context.log(#info, "Query: " # HttpMethod.toText(context.method) # " " # context.request.url);
let response = next();
switch (response) {
case (#response(r)) context.log(#info, "Response: " # debug_show(r.statusCode));
case (#upgrade) context.log(#info, "Response: Upgrade to update call");
};
response
};
handleUpdate =func(context : HttpContext.HttpContext, next : App.NextAsync) : async*App.HttpResponse {
context.log(#info, "Update: " # HttpMethod.toText(context.method) # " " # context.request.url);
let response =await* next();
context.log(#info, "Response: " # debug_show(response.statusCode));
response
};
}
}The routing system supports:
- Path parameters (
/users/{id}) - Nested routes with prefixes
- HTTP method-specific handlers
- Query, update, and async handlers
- Authorization controls
Liminal provides three types of route handlers to match different execution requirements:
For read-only operations that don't modify state. These execute as fast query calls on the Internet Computer.
Router.get("/users", #query_(func(context : RouteContext.RouteContext) : HttpResponse {
// Read-only logic
context.buildResponse(#ok, #text("User list"))
}))For operations that modify state or need async capabilities. Update handlers come in three variants:
Sync Update (#sync) - Synchronous update handler without system access:
Router.post("/users", #update(#sync(func(context : RouteContext.RouteContext) : HttpResponse {
// Modify state synchronously
context.buildResponse(#created, #text("User created"))
})))Sync System Update (#syncSystem) - Synchronous update handler with <system> access:
Router.post("/data", #update(#syncSystem(func<system>(context : RouteContext.RouteContext) : HttpResponse {
// Modify state with system access
context.buildResponse(#ok, #text("Data updated"))
})))Async Update (#async_) - Asynchronous handler for inter-canister calls:
Router.put("/users/{id}", #update(#async_(func(context : RouteContext.RouteContext) : async*HttpResponse {
let result =await* externalCanister.updateUser(userId);
context.buildResponse(#ok, #text("User updated"))
})))For operations that start as queries but can upgrade to updates when needed. This is useful for optimistic reads that may need to write:
Router.get("/data", #upgradableQuery({
queryHandler =func(context : RouteContext.RouteContext) : { #response : HttpResponse; #upgrade } {
// Try to handle as queryif (canHandleAsQuery()) {
#response(context.buildResponse(#ok, #text("Data")))
} else {
#upgrade // Upgrade to update call
}
};
updateHandler = #async_(func(context : RouteContext.RouteContext) : async*HttpResponse {
// Handle as update after upgradeawait* performUpdate();
context.buildResponse(#ok, #text("Data updated"))
});
}))// Route configuration examplelet routerConfig = {
prefix = ?"/api"; // All routes with have prefix `/api`
identityRequirement =null; // Default identity requirement for all routes
routes = [
// Group adds a prefix to all nested routes of `/users`Router.group(
"/users",
[
Router.get("/", #query_(getAllUsers)), // GET + query call -> getAllUsersRouter.post("/", #update(#sync(createUser))), // POST + update call -> createUserRouter.get("/{id}", #query_(getUserById)), // GET + query call -> getUserByIdRouter.put("/{id}", #update(#async_(updateUser))), // PUT + update call (using async method) -> updateUserRouter.delete("/{id}", #update(#sync(deleteUser))) // DELETE + update call -> deleteUser
]
)
]
};Liminal provides a flexible and powerful path matching system that supports various path patterns:
Basic routes with fixed path segments:
Router.get("/users", #query_(getAllUsers))
Router.get("/api/products", #query_(getProducts))Capture dynamic values from the URL using curly braces:
// Matches: /users/123, /users/abcRouter.get("/users/{id}", #query_(getUserById))
// Multiple parameters// Matches: /blog/2023/05/hello-worldRouter.get("/blog/{year}/{month}/{slug}", #query_(getBlogPost))Access parameters in your handler:
func getUserById(context : RouteContext.RouteContext) : Route.HttpResponse {
let userId : Text= context.getRouteParam("id"); // or getRouteParamOrNull("id")// ...
}Matches exactly one segment in the path:
// Matches: /files/document.txt, /files/image.jpg// Does NOT match: /files/folder/document.txtRouter.get("/files/*", #query_(getFile))
// Can appear in the middle of a path// Matches: /files/document.txt/versionsRouter.get("/files/*/versions", #query_(getFileVersions))Matches any number of segments (including zero):
// Matches: /api, /api/users, /api/users/123/profileRouter.get("/api/**", #query_(handleApiRequest))
// Can appear in the middle of a path// Matches: /api/info, /api/users/123/infoRouter.get("/api/**/info", #query_(getApiInfo))The HttpContext provides access to request details:
- Path and query parameters
- Headers
- Request body (with JSON parsing helpers)
- HTTP method
- Identity (for authentication)
publicfunc handleRequest(context : RouteContext.RouteContext) : Route.HttpResponse {
// Access route parameterslet id = context.getRouteParam("id");
// Access query parameterslet filter = context.getQueryParam("filter");
// Access headerslet authorization = context.getHeader("Authorization");
// Get authenticated identitylet identity = context.getIdentity();
// Parse JSON bodylet result = context.parseJsonBody<CreateRequest>(deserializeCreateRequest);
// Return a responselet response = context.buildResponse(#ok, #content(#Record([("id", #number(#int(id)))])));
// Log
context.log(#info, "Created item with id: " # id)
}The framework includes built-in content negotiation that converts Candid data to various formats based on the client's Accept header:
// Return data using automatic content negotiation
context.buildResponse(#ok, #content(myCandidData))The #content response kind takes a Candid representation of your data and uses the client's Accept header to determine the appropriate format (JSON, CBOR, Candid, or XML). This works around Motoko's lack of reflection by using Candid as the common intermediate format - Motoko's to_candid converts your types to Candid, which is then converted to the requested format.
Liminal provides built-in support for handling file uploads via multipart/form-data requests. The file upload functionality allows you to easily access uploaded files within your route handlers:
func(context : RouteContext.RouteContext) : Route.HttpResponse {
// Access all uploaded fileslet files = context.getUploadedFiles();
// Process each uploaded filefor (file in files.vals()) {
// Each file has: fieldName, filename, contentType, size, and contentlet fieldName = file.fieldName; // Form field namelet filename = file.filename; // Original filenamelet contentType = file.contentType; // MIME typelet size = file.size; // Size in byteslet content = file.content; // Blob containing file data// Process the file as needed...
};
return context.buildResponse(#ok, #text("Upload successful"));
}The getUploadedFiles() method automatically parses the multipart/form-data content from the request and returns information about each uploaded file. This makes it straightforward to handle file uploads without needing to manually parse complex multipart boundaries and headers.
Handles route matching and dispatching to the appropriate handler.
RouterMiddleware.new(routerConfig)Configures Cross-Origin Resource Sharing.
CORSMiddleware.default()
// Or with custom optionsCORSMiddleware.new({
allowOrigins = ["https://yourdomain.com"];
allowMethods = [#get, #post, #put, #delete];
allowHeaders = ["Content-Type", "Authorization"];
maxAge = ?86400;
allowCredentials =true;
exposeHeaders = ["Content-Length"];
})Handles JSON Web Token authentication and parsing.
JWTMiddleware.new({
locations = [#header("Authorization"), #cookie("jwt"), #queryString("token")];
validation = {
audience = #skip;
issuer = #skip;
signature = #skip;
notBefore =false;
expiration =false;
};
})
// Or use default settingsJWTMiddleware.new({
locations =JWTMiddleware.defaultLocations;
validation = {
audience = #skip;
issuer = #skip;
signature = #skip;
notBefore =false;
expiration =false;
};
})Automatically compresses HTTP responses for better performance.
CompressionMiddleware.default()
// Or with custom optionsCompressionMiddleware.new({
minSize =1024; // Minimum size in bytes to apply compression
mimeTypes = [
"text/",
"application/javascript",
"application/json",
"application/xml"
];
skipCompressionIf =null;
})Protects your API from abuse by limiting request rates.
RateLimiterMiddleware.new({
limit =100; // Maximum requests per window
windowSeconds =60; // Time window in seconds
includeResponseHeaders =true;
limitExceededMessage = ?"Rate limit exceeded. Try again later.";
keyExtractor = #ip; // Use client IP as the rate limit key
skipIf =null;
})Enforces authentication requirements for specific routes.
RequireAuthMiddleware.new(#authenticated)
// Or with a custom validation functionRequireAuthMiddleware.new(#custom(func(identity : Identity) : Bool {
// Custom validation logiclet ?id = identity.getId() elsereturnfalse;
// Check roles, permissions, etc.returntrue;
}))Provides session management with configurable storage and cookie options.
// Use default in-memory session storeSessionMiddleware.inMemoryDefault()
// Or with custom configurationSessionMiddleware.new({
cookieName ="session";
idleTimeout =1200; // 20 minutes in seconds
cookieOptions = {
path ="/";
secure =true;
httpOnly =true;
sameSite = ?#lax;
maxAge =null;
};
store = myCustomSessionStore;
idGenerator = generateCustomSessionId;
})Access session data in route handlers:
func handleRequest(context : RouteContext.RouteContext) : Route.HttpResponse {
// Get session (automatically created if needed)let ?session = context.session else {
return context.buildResponse(#internalServerError, #error(#message("Session unavailable")));
};
// Store data in session
session.set("user_id", "123");
session.set("preferences", "dark_mode");
// Retrieve data from sessionlet ?userId = session.get("user_id") else {
return context.buildResponse(#unauthorized, #error(#message("Not logged in")));
};
// Remove specific key
session.remove("temp_data");
// Clear entire session
session.clear();
context.buildResponse(#ok, #text("Session updated"));
}Provides Cross-Site Request Forgery protection with configurable token validation.
// Use with session storageCSRFMiddleware.new(CSRFMiddleware.defaultConfig({
get =func() : ?Text {
// Get token from session or other storagenull
};
set =func(token : Text) {
// Store token in session or other storage
};
}))
// Or with custom configurationCSRFMiddleware.new({
tokenTTL =1_800_000_000_000; // 30 minutes in nanoseconds
tokenStorage = myTokenStorage;
headerName ="X-CSRF-Token";
protectedMethods = [#post, #put, #patch, #delete];
exemptPaths = ["/api/public"];
tokenRotation = #perRequest;
})CSRF tokens are automatically generated for GET requests and validated for protected HTTP methods. Include the token in your forms or AJAX requests using the configured header name.
Serves static files with configurable caching.
AssetsMiddleware.new({
prefix = ?"/static";
store = assetStore;
indexAssetPath = ?"/index.html";
cache = {
default = #public_({
immutable =false;
maxAge =3600;
});
rules = [
{
pattern ="/*.css";
cache = #public_({
immutable =true;
maxAge =86400;
});
}
];
};
})Configures security policies for your application.
CSPMiddleware.default()
// Or with custom optionsCSPMiddleware.new({
defaultSrc = ["'self'"];
scriptSrc = ["'self'", "'unsafe-inline'", "https://trusted-scripts.com"];
connectSrc = ["'self'", "https://api.example.com"];
// Additional CSP directives...
})Provides secure OAuth 2.0 authentication with PKCE for popular providers. PKCE (Proof Key for Code Exchange) is used for all OAuth flows, eliminating the need to store client secrets.
importOAuthMiddleware"mo:liminal/Middleware/OAuth";
let oauthConfig = {
providers = [{
OAuthMiddleware.GitHubwith
name ="GitHub";
clientId ="your-client-id";
scopes = ["read:user", "user:email"];
// PKCE is mandatory - no client secrets needed
}];
siteUrl ="https://your-canister-url.ic0.app";
store =OAuthMiddleware.inMemoryStore();
onLogin =func(context, data) {
// Handle successful login
context.buildRedirectResponse("/dashboard", false);
};
onLogout =func(context, data) {
// Handle logout
context.buildRedirectResponse("/", false);
};
};
OAuthMiddleware.new(oauthConfig)Routes: GET /auth/{provider}/login, GET /auth/{provider}/callback, POST /auth/{provider}/logout
Liminal provides a wrapper around the Internet Computer's asset canister functionality:
importHttpAssets"mo:http-assets";
importAssetCanister"mo:liminal/AssetCanister";
shared ({ caller = initializer }) persistentactorclassActor() = self {
transientlet canisterId =Principal.fromActor(self);
// Initialize asset store (persists across upgrades with persistent actor)let assetStableData =HttpAssets.init_stable_store(canisterId, initializer)
|>HttpAssets.upgrade_stable_store(_);
transientlet setPermissions : HttpAssets.SetPermissions= {
commit = [initializer];
manage_permissions = [initializer];
prepare = [initializer];
};
transientlet assetStore =HttpAssets.Assets(assetStableData, ?setPermissions);
transientlet assetCanister =AssetCanister.AssetCanister(assetStore);
// Use in middlewarelet app =Liminal.App({
middleware = [
AssetsMiddleware.new({
store = assetStore;
}),
];
// ... other config
});
// Expose asset canister methodspublicsharedqueryfunc get(args : HttpAssets.GetArgs) : asyncHttpAssets.EncodedAsset {
assetCanister.get(args);
}
// Additional asset canister methods...
}Custom error handling can be configured via the app's errorSerializer:
importJson"mo:json";
importText"mo:core/Text";
importOption"mo:core/Option";
let app =Liminal.App({
middleware = [ /* ... */ ];
errorSerializer =func(error : HttpContext.HttpError) : HttpContext.ErrorSerializerResponse {
let body =switch (error.data) {
case (#none) #object_([
("error", #string("Error")),
("code", #number(#int(error.statusCode))),
]);
case (#message(message)) #object_([
("error", #string("Custom Error")),
("code", #number(#int(error.statusCode))),
("message", #string(message)),
]);
case (#rfc9457(details)) #object_([
("error", #string("Custom Error")),
("code", #number(#int(error.statusCode))),
("type", #string(details.type_)),
// Additional fields from RFC 9457...
]);
}
|>Json.stringify(_, null)
|>Text.encodeUtf8(_);
{
body = ?body;
headers = [("content-type", "application/json")];
};
};
candidRepresentationNegotiator =Liminal.defaultCandidRepresentationNegotiator;
});The candidRepresentationNegotiator handles the conversion of Candid values to different representations based on the client's Accept header. The default implementation supports converting to JSON, CBOR, Candid, and XML formats.
Liminal provides comprehensive URL normalization to ensure consistent request handling. The urlNormalization configuration controls how URLs are processed before routing:
let app =Liminal.App({
middleware = [/* ... */];
errorSerializer =Liminal.defaultJsonErrorSerializer;
candidRepresentationNegotiator =Liminal.defaultCandidRepresentationNegotiator;
logger =Liminal.buildDebugLogger(#info);
urlNormalization = {
// Path comparison is case-sensitive (/Users != /users)
pathIsCaseSensitive =false;
// Keep trailing slashes (/users/ != /users)
preserveTrailingSlash =false;
// Query parameter keys are case-sensitive (sort != Sort)
queryKeysAreCaseSensitive =false;
// Remove empty path segments (/users//123 -> /users/123)
removeEmptyPathSegments =true;
// Resolve . and .. in paths (/users/../admin -> /admin)
resolvePathDotSegments =true;
// Username in URLs is case-sensitive (user@host != User@host)
usernameIsCaseSensitive =false;
};
});These settings help ensure your application handles URLs consistently regardless of how clients format them.
Version 3 introduces significant improvements to the routing API for better type safety and consistency. Here's what changed:
Old (v2): Method-specific handler functions
// v2 - Multiple specialized methodsRouter.getQuery("/users", getUsersHandler)
Router.getUpdate("/users", getUsersHandler)
Router.getAsyncUpdate("/users", getUsersHandler)
Router.postQuery("/users", createUserHandler)
Router.postUpdate("/users", createUserHandler)
Router.postAsyncUpdate("/users", createUserHandler)
// ... and similar for PUT, PATCH, DELETENew (v3): Unified methods with handler type variants
// v3 - Single method per HTTP verb with explicit handler typeRouter.get("/users", #query_(getUsersHandler))
Router.post("/users", #update(#sync(createUserHandler)))
Router.put("/users/{id}", #update(#async_(updateUserHandler)))The following methods have been removed in v3:
Router.getQuery()→ UseRouter.get()with#query_()handlerRouter.getUpdate()→ UseRouter.get()with#update(#sync())handlerRouter.getAsyncUpdate()→ UseRouter.get()with#update(#async_())handlerRouter.postQuery()→ UseRouter.post()with#query_()handlerRouter.postUpdate()→ UseRouter.post()with#update(#sync())handlerRouter.postAsyncUpdate()→ UseRouter.post()with#update(#async_())handlerRouter.putQuery()→ UseRouter.put()with#query_()handlerRouter.putUpdate()→ UseRouter.put()with#update(#sync())handlerRouter.putAsyncUpdate()→ UseRouter.put()with#update(#async_())handlerRouter.patchQuery()→ UseRouter.patch()with#query_()handlerRouter.patchUpdate()→ UseRouter.patch()with#update(#sync())handlerRouter.patchAsyncUpdate()→ UseRouter.patch()with#update(#async_())handlerRouter.deleteQuery()→ UseRouter.delete()with#query_()handlerRouter.deleteUpdate()→ UseRouter.delete()with#update(#sync())handlerRouter.deleteAsyncUpdate()→ UseRouter.delete()with#update(#async_())handler
Old (v2):
publictypeRouteHandler= {
#syncQuery : RouteContext->HttpResponse;
#syncUpdate : <system>(RouteContext) ->HttpResponse;
#asyncUpdate : RouteContext->async*HttpResponse;
};New (v3):
publictypeUpdateHandlerKind= {
#sync : (RouteContext) ->HttpResponse;
#syncSystem : <system>(RouteContext) ->HttpResponse;
#async_ : (RouteContext) ->async*HttpResponse;
};
publictypeRouteHandler= {
#query_ : (RouteContext) ->HttpResponse;
#upgradableQuery : {
queryHandler : (RouteContext) -> { #response : HttpResponse; #upgrade };
updateHandler : UpdateHandlerKind;
};
#update : UpdateHandlerKind;
};The App constructor now requires a urlNormalization configuration:
Old (v2):
let app =Liminal.App({
middleware = [...];
errorSerializer =Liminal.defaultJsonErrorSerializer;
candidRepresentationNegotiator =Liminal.defaultCandidRepresentationNegotiator;
logger =Liminal.buildDebugLogger(#info);
});New (v3):
let app =Liminal.App({
middleware = [...];
errorSerializer =Liminal.defaultJsonErrorSerializer;
candidRepresentationNegotiator =Liminal.defaultCandidRepresentationNegotiator;
logger =Liminal.buildDebugLogger(#info);
urlNormalization = {
pathIsCaseSensitive =false;
preserveTrailingSlash =false;
queryKeysAreCaseSensitive =false;
removeEmptyPathSegments =true;
resolvePathDotSegments =true;
usernameIsCaseSensitive =false;
};
});- More Consistent API: Single method per HTTP verb with variant for handler type
- More Flexible: New
#upgradableQueryand#syncSystemvariants provide more control - Clearer Intent: Code shows whether a route is query or update at a glance
Run the test suite with:
mops testContributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the LICENSE file for details.