Routing
Routing in Spaceport is the process of mapping incoming HTTP requests to specific handlers in your application. Unlike traditional web frameworks that rely on configuration files or annotation-based routing tables, Spaceport uses its Alerts event system to handle routing declaratively. This approach provides powerful flexibility, allowing you to create everything from simple static routes to complex dynamic patterns with minimal boilerplate.
At its core, routing in Spaceport means listening for HTTP request events using the @Alert annotation and responding with the appropriate content. This unified event-driven approach means routing integrates seamlessly with the rest of your application's event handling.
# Core Concepts
Spaceport's routing system is built on several key concepts that work together to provide a flexible and intuitive way to handle HTTP requests.
## Route Events
Every HTTP request that arrives at your Spaceport application triggers multiple events in sequence. Understanding this event flow is crucial for building middleware and handling requests effectively.
Event Firing Sequence:
1. on [route] hit - Fires first for the specific route (any HTTP method except OPTIONS and HEAD)
2. on [route] [method] - Fires for the specific route with explicit HTTP method
3. on page hit - Fires last for every HTTP request, regardless of whether previous handlers responded
The event string for specific routes follows this pattern:
on [route] [method]
- Route: The URL path (e.g.,
/,/products,/api/users) - Method: The HTTP verb (GET, POST, PUT, DELETE, etc.)
For GET requests, Spaceport provides a shorthand: on [route] hit instead of on [route] GET.
Important: The on page hit alert fires for every HTTP request after route-specific handlers. This makes it ideal for logging, analytics, error handling, and other cross-cutting concerns that should apply to all requests.
## Route Handlers
Route handlers are static methods annotated with @Alert that respond to route events. They receive an HttpResult object containing the request context and methods to construct the response.
import spaceport.computer.alerts.Alert
import spaceport.computer.alerts.results.HttpResult
class Router {
@Alert('on / hit')
static _homepage(HttpResult r) {
r.writeToClient("Welcome to Spaceport!")
}
}
Handler Requirements:
- Must be a static method
- Must accept exactly one parameter of type HttpResult
- Must be in a class within a source module directory (see Source Modules)
## The HttpResult Object
The HttpResult object is your interface to both the incoming request and the outgoing response. It provides:
Request Information:
- r.context.target - The requested path
- r.context.method - HTTP method (GET, POST, etc.)
- r.context.data - Query parameters or form data (Map)
- r.context.headers - Request headers (Map)
- r.context.cookies - Request cookies (Map)
- r.context.client - Authenticated user (if logged in)
- r.context.dock - Session-specific storage (Cargo)
Response Methods:
- r.writeToClient(content) - Send response content
- r.setStatus(code) - Set HTTP status code
- r.setContentType(type) - Set Content-Type header
- r.setRedirectUrl(url) - Redirect to another URL
- r.addResponseHeader(name, value) - Add response headers
- r.addResponseCookie(name, value) - Add response cookies
Control Flags:
- r.called - Boolean indicating if any handler has written a response
- r.cancelled - Set to true to prevent subsequent handlers from executing
See the Alerts documentation for the complete HttpResult API reference.
## The Universal 'on page hit' Alert
The on page hit alert is special—it fires for every HTTP request after route-specific handlers have executed. This makes it the perfect place for cross-cutting concerns that should apply to all requests.
When to use on page hit:
- Logging and analytics - Track all requests
- Error handling - Catch requests that no route handled
- Global headers - Add headers to all responses
- Performance monitoring - Measure response times
- Cleanup tasks - Close connections, clear temporary data
class GlobalHandlers {
// Fires for EVERY request
@Alert('on page hit')
static _logAllRequests(HttpResult r) {
println "[${new Date()}] ${r.context.method} ${r.context.target} - ${r.context.response.status}"
}
// Catch 404s - use negative priority to run last
@Alert(value = 'on page hit', priority = -100)
static _handle404(HttpResult r) {
if (!r.called) {
r.setStatus(404)
new Launchpad().assemble(['errors/404.ghtml']).launch(r)
}
}
}
The r.called flag is set to true when any handler writes a response. This lets your on page hit handlers know whether a route was successfully matched and handled.
# Basic Routing
Let's start with the fundamental routing patterns you'll use most often.
## Static Routes
Static routes match exact URL paths. These are the simplest and most common route types.
class Router {
// Homepage
@Alert('on / hit')
static _homepage(HttpResult r) {
new Launchpad().assemble(['home.ghtml']).launch(r)
}
// About page
@Alert('on /about hit')
static _about(HttpResult r) {
new Launchpad().assemble(['about.ghtml']).launch(r)
}
// Contact page
@Alert('on /contact hit')
static _contact(HttpResult r) {
new Launchpad().assemble(['contact.ghtml']).launch(r)
}
}
## HTTP Methods
Different HTTP methods represent different operations. Use the explicit method syntax to handle non-GET requests.
class ApiRouter {
// GET /api/users - List users
@Alert('on /api/users hit')
static _listUsers(HttpResult r) {
def users = User.all()
r.writeToClient(['users': users.collect { it.toMap() }])
}
// POST /api/users - Create user
@Alert('on /api/users POST')
static _createUser(HttpResult r) {
def name = r.context.data.name
def email = r.context.data.email
def user = new User(name: name, email: email)
user.save()
r.setStatus(201)
r.writeToClient(['id': user._id])
}
// PUT /api/users - Update user (bulk)
@Alert('on /api/users PUT')
static _updateUsers(HttpResult r) {
// Handle bulk updates
r.setStatus(204)
}
// DELETE /api/users - Delete all users
@Alert('on /api/users DELETE')
static _deleteUsers(HttpResult r) {
// Handle bulk deletion
r.setStatus(204)
}
}
Common HTTP Methods:
- GET - Retrieve resources (use hit shorthand)
- POST - Create new resources
- PUT - Update existing resources (full replacement)
- PATCH - Partially update resources
- DELETE - Remove resources
- OPTIONS - Describe communication options
- HEAD - GET without response body
## Nested Routes
Routes can be organized hierarchically to reflect your application's structure.
class Router {
@Alert('on /admin hit')
static _adminDashboard(HttpResult r) {
new Launchpad().assemble(['admin/dashboard.ghtml']).launch(r)
}
@Alert('on /admin/users hit')
static _adminUsers(HttpResult r) {
new Launchpad().assemble(['admin/users.ghtml']).launch(r)
}
@Alert('on /admin/settings hit')
static _adminSettings(HttpResult r) {
new Launchpad().assemble(['admin/settings.ghtml']).launch(r)
}
}
# Dynamic Routing
Dynamic routes use regular expressions to match patterns and capture URL parameters. This is one of Spaceport's most powerful routing features.
## Regex Route Matching
Prefix your event string with ~ to enable regex matching. Captured groups are available in r.matches.
class ArticleRouter {
// Match: /articles/123, /articles/hello-world, etc.
@Alert('~on /articles/(.*) hit')
static _viewArticle(HttpResult r) {
def articleId = r.matches[0]
def article = Article.findById(articleId)
if (!article) {
r.setStatus(404)
r.writeToClient("Article not found")
return
}
// Pass data to template via r.context.data
r.context.data.article = article
new Launchpad()
.assemble(['article.ghtml'])
.launch(r)
}
}
Important Notes:
- Regex patterns are anchored (must match the entire route)
- Spaces in patterns are treated as \s (whitespace) automatically
- Captured groups are strings (convert to numbers if needed)
- Use raw strings or escape backslashes: ~on /path\\d+
## Multiple Parameters
Capture multiple URL segments with multiple regex groups.
class UserPostRouter {
// Match: /users/alice/posts/123
@Alert('~on /users/(.)/posts/(.) hit')
static _viewUserPost(HttpResult r) {
def username = r.matches[0]
def postId = r.matches[1]
def user = User.findByUsername(username)
if (!user) {
r.setStatus(404)
return
}
def post = Post.findById(postId)
if (!post || post.userId != user._id) {
r.setStatus(404)
return
}
// Pass data to template
r.context.data.post = post
r.context.data.user = user
new Launchpad()
.assemble(['post.ghtml'])
.launch(r)
}
}
## Constrained Parameters
Use regex patterns to constrain what values parameters can match.
class Router {
// Only match numeric IDs: /products/123
@Alert('~on /products/(\\d+) hit')
static _viewProduct(HttpResult r) {
def productId = r.matches[0].toInteger()
// ... handle product view
}
// Match year and month: /archive/2024/03
@Alert('~on /archive/(\\d{4})/(\\d{2}) hit')
static _viewArchive(HttpResult r) {
def year = r.matches[0].toInteger()
def month = r.matches[1].toInteger()
// ... handle archive view
}
// Match specific actions: /users/123/edit or /users/123/delete
@Alert('~on /users/(\\d+)/(edit|delete) hit')
static _userAction(HttpResult r) {
def userId = r.matches[0].toInteger()
def action = r.matches[1]
// ... handle user action
}
}
## Optional Parameters
Use ? to make URL segments optional.
class Router {
// Match: /search or /search/query
@Alert('~on /search(/.*)? hit')
static _search(HttpResult r) {
def query = r.matches[0] ? r.matches[0][1..-1] : null // Remove leading /
if (!query) {
// Show search form
new Launchpad().assemble(['search-form.ghtml']).launch(r)
} else {
// Show results
def results = searchFor(query)
r.context.data.results = results
r.context.data.query = query
new Launchpad()
.assemble(['search-results.ghtml'])
.launch(r)
}
}
}
# Query Parameters and Form Data
Access query parameters and form data through r.context.data, which is a standard Groovy Map.
## Query Parameters
Query parameters are available for all request methods but are most common with GET requests.
class SearchRouter {
@Alert('on /search hit')
static _search(HttpResult r) {
// Access query parameters from the URL
// Example: /search?q=spaceport&page=2&sort=date
def query = r.context.data.q
def page = r.context.data.page?.toInteger() ?: 1
def sort = r.context.data.sort ?: 'relevance'
// Perform search with parameters
def results = performSearch(query, page, sort)
// Pass data to template
r.context.data.results = results
r.context.data.query = query
r.context.data.page = page
r.context.data.sort = sort
new Launchpad()
.assemble(['search-results.ghtml'])
.launch(r)
}
}
Standard Map Access:
- data.propertyName or data['key'] - Direct access
- data.get(key, defaultValue) - Get with default value
- Use Groovy's safe navigation (?.) and elvis operator (?:) for null safety
- Convert strings to other types as needed (.toInteger(), .toBoolean(), etc.)
Optional: Using Cargo for Enhanced Features
If you need Cargo's enhanced methods (like getNumber() or getBoolean()), you can wrap the data map:
@Alert('on /search hit')
static _search(HttpResult r) {
def data = new Cargo(r.context.data)
// Now you can use Cargo methods
def page = data.getNumber('page', 1)
def enabled = data.getBoolean('enabled', false)
// ... etc
}
See the Cargo documentation for more on Cargo's enhanced map features.
## Form Data
POST, PUT, and PATCH requests can contain form data in the request body. Spaceport automatically handles multiple content types:
Form-Encoded Data (application/x-www-form-urlencoded):
class FormRouter {
@Alert('on /contact POST')
static _submitContact(HttpResult r) {
// Access form fields
def name = r.context.data.name
def email = r.context.data.email
def message = r.context.data.message
// Validate input
if (!name || !email || !message) {
r.setStatus(400)
r.writeToClient(['error': 'All fields are required'])
return
}
// Process the form
sendContactEmail(name, email, message)
// Redirect to thank you page
r.setRedirectUrl('/contact/thank-you')
}
}
JSON Data (application/json):
class ApiRouter {
@Alert('on /api/users POST')
static _createUser(HttpResult r) {
// JSON body is automatically parsed into r.context.data
def user = [
name: r.context.data.name,
email: r.context.data.email,
role: r.context.data.role
]
def savedUser = User.create(user)
r.setStatus(201)
r.writeToClient(['id': savedUser._id])
}
}
Spaceport automatically detects the Content-Type header and parses the request body accordingly.
## File Uploads
Multipart form data containing files is accessible through r.context.data. Spaceport automatically handles file uploads up to 50MB per file.
class UploadRouter {
@Alert('on /upload POST')
static _handleUpload(HttpResult r) {
// Access uploaded file
def file = r.context.data.file
if (!file) {
r.setStatus(400)
r.writeToClient(['error': 'No file uploaded'])
return
}
// File data is a map with 'name' and 'data' keys
def filename = file.name // Original filename
def fileData = file.data // Byte array of file content
// Save the file
def savedPath = saveUploadedFile(fileData, filename)
r.writeToClient([
'success': true,
'filename': filename,
'path': savedPath
])
}
}
File Upload Limits:
- Maximum file size: 50MB per file
- Maximum request size: 50MB total
- Files are written to /tmp during processing
# Middleware Patterns
Spaceport's alert priority system enables middleware-style request processing, where multiple handlers can process the same request in sequence.
## Automatic HTTP Features
Spaceport automatically handles several HTTP concerns for you:
CORS Headers:
Spaceport automatically adds CORS headers to all responses:
- Access-Control-Allow-Origin - Set to the request's Origin header
- Access-Control-Allow-Credentials - Set to true
- Access-Control-Allow-Methods - GET, PUT, POST, PATCH, DELETE, OPTIONS
- Access-Control-Allow-Headers - Content-Type
Cache Control:
By default, Spaceport disables caching for dynamic content:
- Cache-Control: no-cache, no-store, must-revalidate
- Pragma: no-cache
- Expires: 0
You can override these headers in your handlers if you need caching for specific routes.
OPTIONS and HEAD Requests: Spaceport automatically handles OPTIONS and HEAD requests with a 200 status, so you don't need to write handlers for these methods unless you need custom behavior.
class CachingRouter {
@Alert('on /static/data hit')
static _serveStaticData(HttpResult r) {
// Override default no-cache behavior for this route
r.addResponseHeader('Cache-Control', 'public, max-age=3600')
r.addResponseHeader('ETag', generateETag())
r.writeToClient(getStaticData())
}
}
## Authentication Middleware
Use high-priority alerts to check authentication before route handlers execute.
class AuthenticationMiddleware {
// High priority - runs first
@Alert(value = 'on page hit', priority = 100)
static _requireAuth(HttpResult r) {
def protectedPaths = ['/admin', '/dashboard', '/api']
// Check if this path requires authentication
def requiresAuth = protectedPaths.any { r.context.target.startsWith(it) }
if (requiresAuth && !r.context.client) {
r.setRedirectUrl('/login')
r.cancelled = true // Stop other alerts from executing
}
}
}
## Request Logging
Log all requests at a lower priority to ensure logging happens even if the request is cancelled.
class RequestLogger {
@Alert(value = 'on page hit', priority = 50)
static _logRequest(HttpResult r) {
println "[${new Date()}] ${r.context.method} ${r.context.target}"
// Store request details for analytics
Analytics.track([
method: r.context.method,
path: r.context.target,
userAgent: r.context.headers.'User-Agent',
timestamp: r.context.time,
clientId: r.context.cookies.'spaceport-uuid'
])
}
}
Note: Spaceport automatically assigns a spaceport-uuid cookie to each client for identification. This UUID is available in r.context.cookies and persists for 1 year. The associated Client object is available in r.context.client.
Cookies set by your application inherit security settings based on environment: - Production mode: Secure flag is automatically set (HTTPS only) - Debug mode: Secure flag is not set (allows HTTP for local development)
## CORS Customization
While Spaceport handles basic CORS automatically, you may need to customize CORS behavior for specific routes or add additional headers.
class CustomCorsMiddleware {
@Alert(value = 'on /api page hit', priority = 90)
static _customCorsForApi(HttpResult r) {
// Add additional CORS headers beyond the defaults
r.addResponseHeader('Access-Control-Max-Age', '86400')
r.addResponseHeader('Access-Control-Expose-Headers', 'X-Request-Id, X-Response-Time')
// Allow additional headers for API requests
r.setResponseHeader('Access-Control-Allow-Headers',
'Content-Type, Authorization, X-Api-Key')
}
}
## Request Pipeline
Chain multiple handlers together to process requests in stages.
class ImageUploadPipeline {
// Stage 1: Validate (priority 30)
@Alert(value = 'on /api/upload POST', priority = 30)
static _validate(HttpResult r) {
def file = r.context.data.file
if (!isValidImageType(file)) {
r.setStatus(400)
r.writeToClient(['error': 'Invalid file type'])
r.cancelled = true
return
}
r.context.validated = true
}
// Stage 2: Process (priority 20)
@Alert(value = 'on /api/upload POST', priority = 20)
static _process(HttpResult r) {
if (!r.context.validated) return
def file = r.context.data.file
def processed = resizeAndOptimize(file)
r.context.processedImage = processed
}
// Stage 3: Save (priority 10)
@Alert(value = 'on /api/upload POST', priority = 10)
static _save(HttpResult r) {
if (!r.context.processedImage) return
def url = saveToStorage(r.context.processedImage)
r.writeToClient(['url': url])
}
}
Priority Guidelines: - 100+: Critical security/authentication checks - 50-99: Logging, metrics, CORS - 0-49: Route-specific middleware - Default (0): Standard route handlers - Negative: Cleanup, error handling, 404s
# Response Types
Spaceport supports various response types through the HttpResult methods.
## Plain Text
Send simple text responses.
@Alert('on /api/status hit')
static _status(HttpResult r) {
r.setContentType('text/plain')
r.writeToClient("Server is running")
}
## JSON
Return JSON by passing a Map or List to writeToClient().
@Alert('on /api/user hit')
static _getUser(HttpResult r) {
def user = [
id: 123,
name: "Alice",
email: "alice@example.com"
]
// Automatically sets Content-Type: application/json
r.writeToClient(user)
}
## HTML from Launchpad
Render HTML templates using Launchpad. Data is passed to templates through r.context.data.
@Alert('on /profile hit')
static _profile(HttpResult r) {
def user = User.findById(r.context.client.id)
// Add data to the context so templates can access it
r.context.data.user = user
r.context.data.pageTitle = 'User Profile'
new Launchpad()
.assemble(['profile.ghtml'])
.launch(r)
}
In the template (profile.ghtml):
<h1>${data.pageTitle}</h1>
<p>Welcome, ${data.user.name}!</p>
Available in Templates:
Templates automatically have access to:
- data - The r.context.data map with your custom data
- r - The full HttpResult object
- context - Shortcut to r.context
- client - The authenticated client
- dock - Session-specific Cargo storage
- cookies - Request cookies
See the Launchpad documentation for more on template rendering.
## File Downloads
Serve files for download with appropriate headers.
@Alert('on /download hit')
static _download(HttpResult r) {
def filename = r.context.data.file
def file = new File("/downloads/${filename}")
if (!file.exists()) {
r.setStatus(404)
return
}
// Mark as attachment to trigger download
r.markAsAttachment(filename)
// Automatically sets Content-Type based on file extension
r.writeToClient(file)
}
## Redirects
Redirect to another URL with setRedirectUrl().
@Alert('on /old-page hit')
static _oldPage(HttpResult r) {
r.setRedirectUrl('/new-page')
}
@Alert('on /login POST')
static _login(HttpResult r) {
def username = r.context.data.username
def password = r.context.data.password
if (authenticate(username, password)) {
r.setRedirectUrl('/dashboard')
} else {
r.setRedirectUrl('/login?error=invalid')
}
}
## Custom Status Codes
Set appropriate HTTP status codes for different scenarios.
class ApiRouter {
@Alert('on /api/resource POST')
static _create(HttpResult r) {
def resource = createResource(r.context.data)
r.setStatus(201) // Created
r.writeToClient(['id': resource._id])
}
@Alert('on /api/resource DELETE')
static _delete(HttpResult r) {
deleteResource(r.context.data.id)
r.setStatus(204) // No Content
}
@Alert('~on /api/resource/(.*) hit')
static _get(HttpResult r) {
def resource = findResource(r.matches[0])
if (!resource) {
r.setStatus(404) // Not Found
r.writeToClient(['error': 'Resource not found'])
return
}
r.writeToClient(resource)
}
}
Common Status Codes: - 200: OK (default for successful responses) - 201: Created (new resource created) - 204: No Content (successful with no response body) - 301: Moved Permanently (permanent redirect) - 302: Found (temporary redirect, default for redirects) - 400: Bad Request (invalid client input) - 401: Unauthorized (authentication required) - 403: Forbidden (authenticated but not authorized) - 404: Not Found (resource doesn't exist) - 500: Internal Server Error (server-side error)
# Error Handling
Proper error handling ensures your application responds gracefully to unexpected situations.
## 404 Not Found
Handle missing routes with a catch-all low-priority alert.
class ErrorHandler {
@Alert(value = 'on page hit', priority = -100)
static _handle404(HttpResult r) {
// Only handle if no other handler responded
if (!r.called) {
r.setStatus(404)
new Launchpad().assemble(['errors/404.ghtml']).launch(r)
}
}
}
The r.called property is true if any handler has written a response. This prevents the 404 handler from overriding legitimate responses.
## Custom Error Pages
Create error pages for different status codes.
class ErrorHandler {
@Alert(value = 'on page hit', priority = -100)
static _handleErrors(HttpResult r) {
if (r.called) return
// Check if an error status was set by another handler
def status = r.context.response.status
if (status >= 400 && status < 500) {
// Client errors (4xx)
r.setStatus(status)
r.context.data.status = status
new Launchpad()
.assemble(['errors/4xx.ghtml'])
.launch(r)
} else if (status >= 500) {
// Server errors (5xx)
r.setStatus(status)
r.context.data.status = status
new Launchpad()
.assemble(['errors/5xx.ghtml'])
.launch(r)
} else {
// No handler responded and no error status
r.setStatus(404)
new Launchpad().assemble(['errors/404.ghtml']).launch(r)
}
}
}
Exception Handling:
If an exception occurs in any route handler, Spaceport automatically:
1. Sets the response status to 500 (Internal Server Error)
2. Attempts to invoke on page hit handlers for recovery
3. Logs the exception for debugging
This allows your on page hit error handlers to provide graceful error pages even when exceptions occur.
## API Error Responses
Return consistent error formats for API endpoints.
class ApiErrorHandler {
static sendError(HttpResult r, Integer status, String message, Map details = [:]) {
r.setStatus(status)
r.writeToClient([
error: [
status: status,
message: message,
details: details
]
])
}
}
class ApiRouter {
@Alert('on /api/user POST')
static _createUser(HttpResult r) {
def email = r.context.data.email
// Validation
if (!email || !isValidEmail(email)) {
ApiErrorHandler.sendError(r, 400, 'Invalid email address', [
field: 'email',
received: email
])
return
}
// Check for duplicate
if (User.findByEmail(email)) {
ApiErrorHandler.sendError(r, 409, 'Email already exists', [
field: 'email'
])
return
}
// Create user
def user = new User(email: email)
user.save()
r.setStatus(201)
r.writeToClient(['id': user._id])
}
}
# Advanced Patterns
## Route Organization
Organize routes logically across multiple classes to keep your codebase maintainable.
// Public pages
class PublicRouter {
@Alert('on / hit')
static _home(HttpResult r) { / ... / }
@Alert('on /about hit')
static _about(HttpResult r) { / ... / }
@Alert('on /contact hit')
static _contact(HttpResult r) { / ... / }
}
// Admin pages
class AdminRouter {
@Alert('on /admin hit')
static _dashboard(HttpResult r) { / ... / }
@Alert('on /admin/users hit')
static _users(HttpResult r) { / ... / }
@Alert('on /admin/settings hit')
static _settings(HttpResult r) { / ... / }
}
// API routes
class ApiRouter {
@Alert('on /api/users hit')
static _listUsers(HttpResult r) { / ... / }
@Alert('on /api/users POST')
static _createUser(HttpResult r) { / ... / }
}
## RESTful Resource Routing
Implement standard RESTful patterns for resources.
class ProductsApi {
// GET /api/products - List all products
@Alert('on /api/products hit')
static _index(HttpResult r) {
def products = Product.all()
r.writeToClient(['products': products.collect { it.toMap() }])
}
// GET /api/products/123 - Show one product
@Alert('~on /api/products/(\\d+) hit')
static _show(HttpResult r) {
def product = Product.findById(r.matches[0].toInteger())
if (!product) {
r.setStatus(404)
r.writeToClient(['error': 'Product not found'])
return
}
r.writeToClient(product.toMap())
}
// POST /api/products - Create product
@Alert('on /api/products POST')
static _create(HttpResult r) {
def product = new Product(
name: r.context.data.name,
price: r.context.data.getNumber('price')
)
product.save()
r.setStatus(201)
r.addResponseHeader('Location', "/api/products/${product._id}")
r.writeToClient(product.toMap())
}
// PUT /api/products/123 - Update product
@Alert('~on /api/products/(\\d+) PUT')
static _update(HttpResult r) {
def product = Product.findById(r.matches[0].toInteger())
if (!product) {
r.setStatus(404)
r.writeToClient(['error': 'Product not found'])
return
}
product.name = r.context.data.name
product.price = r.context.data.getNumber('price')
product.save()
r.writeToClient(product.toMap())
}
// DELETE /api/products/123 - Delete product
@Alert('~on /api/products/(\\d+) DELETE')
static _delete(HttpResult r) {
def product = Product.findById(r.matches[0].toInteger())
if (!product) {
r.setStatus(404)
return
}
product.delete()
r.setStatus(204)
}
}
## Route Versioning
Version your API routes for backward compatibility.
// Version 1 API
class ApiV1 {
@Alert('on /api/v1/users hit')
static _listUsers(HttpResult r) {
def users = User.all()
r.writeToClient(['users': users.collect { [id: it._id, name: it.name] }])
}
}
// Version 2 API with additional fields
class ApiV2 {
@Alert('on /api/v2/users hit')
static _listUsers(HttpResult r) {
def users = User.all()
r.writeToClient([
'users': users.collect {
[id: it._id, name: it.name, email: it.email, created: it.created]
}
])
}
}
## Subdomain Routing
Route based on subdomains by checking the request host.
class SubdomainRouter {
@Alert(value = 'on / hit', priority = 10)
static _routeBySubdomain(HttpResult r) {
def host = r.context.headers.Host
if (host.startsWith('api.')) {
// API subdomain
r.writeToClient(['message': 'API endpoint'])
r.cancelled = true
} else if (host.startsWith('admin.')) {
// Admin subdomain
new Launchpad().assemble(['admin/dashboard.ghtml']).launch(r)
r.cancelled = true
}
// Otherwise, let other handlers process normally
}
}
## Content Negotiation
Return different formats based on the Accept header or file extension.
class ArticleRouter {
@Alert('~on /articles/(.*)(\\.(json|xml))? hit')
static _viewArticle(HttpResult r) {
def articleId = r.matches[0]
def format = r.matches[2] ?: detectFormat(r)
def article = Article.findById(articleId)
if (!article) {
r.setStatus(404)
return
}
if (format == 'json') {
r.writeToClient(article.toMap())
} else if (format == 'xml') {
r.setContentType('application/xml')
r.writeToClient(article.toXml())
} else {
// Default to HTML
r.context.data.article = article
new Launchpad()
.assemble(['article.ghtml'])
.launch(r)
}
}
static detectFormat(HttpResult r) {
def accept = r.context.headers.Accept ?: ''
if (accept.contains('application/json')) return 'json'
if (accept.contains('application/xml')) return 'xml'
return 'html'
}
}
# Best Practices
## Keep Handlers Focused
Each route handler should have a single, clear responsibility.
// Good: Focused handler
@Alert('on /products hit')
static _listProducts(HttpResult r) {
def products = Product.all()
new Launchpad()
.assemble(['products.ghtml'])
.includeFromContext(['products': products])
.launch(r)
}
// Bad: Handler doing too much
@Alert('on /products hit')
static _listProducts(HttpResult r) {
// Mixing concerns
logRequest(r)
checkAuth(r)
validateInput(r)
def products = Product.all()
updateAnalytics(products)
sendMetrics(r)
new Launchpad().assemble(['products.ghtml']).launch(r)
cleanupTempFiles()
}
Use middleware patterns (priority-based alerts) for cross-cutting concerns.
## Validate Input Early
Always validate and sanitize user input before processing.
@Alert('on /api/user POST')
static _createUser(HttpResult r) {
// Validate required fields
def email = r.context.data.email
def password = r.context.data.password
if (!email || !password) {
r.setStatus(400)
r.writeToClient(['error': 'Email and password required'])
return
}
// Validate format
if (!isValidEmail(email)) {
r.setStatus(400)
r.writeToClient(['error': 'Invalid email format'])
return
}
// Proceed with creation
def user = new User(email: email, password: hashPassword(password))
user.save()
r.setStatus(201)
r.writeToClient(['id': user._id])
}
## Use Meaningful Route Names
Choose route paths that clearly describe their purpose.
// Good: Clear, descriptive routes
@Alert('on /users/profile hit')
@Alert('on /products/search hit')
@Alert('on /api/orders/recent hit')
// Bad: Unclear, abbreviated routes
@Alert('on /usr/prof hit')
@Alert('on /prod/srch hit')
@Alert('on /api/ord/rec hit')
## Handle Errors Gracefully
Always provide meaningful error messages and appropriate status codes.
@Alert('~on /articles/(.*) hit')
static _viewArticle(HttpResult r) {
try {
def articleId = r.matches[0]
def article = Article.findById(articleId)
if (!article) {
r.setStatus(404)
new Launchpad().assemble(['errors/404.ghtml']).launch(r)
return
}
new Launchpad()
.assemble(['article.ghtml'])
.includeFromContext(['article': article])
.launch(r)
} catch (Exception e) {
println "Error loading article: ${e.message}"
r.setStatus(500)
new Launchpad().assemble(['errors/500.ghtml']).launch(r)
}
}
## Document Complex Routes
Add comments to explain regex patterns and complex routing logic.
class Router {
// Match blog posts with optional year/month/day hierarchy
// Examples:
// /blog/hello-world
// /blog/2024/hello-world
// /blog/2024/03/hello-world
// /blog/2024/03/15/hello-world
@Alert('~on /blog(?:/(\\d{4}))?(?:/(\\d{2}))?(?:/(\\d{2}))?/(.*) hit')
static _viewPost(HttpResult r) {
def year = r.matches[0]?.toInteger()
def month = r.matches[1]?.toInteger()
def day = r.matches[2]?.toInteger()
def slug = r.matches[3]
// ... handle post retrieval
}
}
## Separate Concerns
Keep routing logic separate from business logic.
// Good: Route handler delegates to service layer
@Alert('on /api/users POST')
static _createUser(HttpResult r) {
def userData = [
email: r.context.data.email,
password: r.context.data.password
]
try {
def user = UserService.createUser(userData)
r.setStatus(201)
r.writeToClient(['id': user._id])
} catch (ValidationException e) {
r.setStatus(400)
r.writeToClient(['error': e.message])
}
}
// Bad: Business logic mixed into route handler
@Alert('on /api/users POST')
static _createUser(HttpResult r) {
def email = r.context.data.email
def password = r.context.data.password
// Too much business logic in the route handler
if (!email.matches(/^[^@]+@[^@]+$/)) { / ... / }
def hash = new BCryptPasswordEncoder().encode(password)
def user = Document.getNew('user')
user.fields.email = email
user.fields.passwordHash = hash
user.fields.created = new Date()
user.save()
sendWelcomeEmail(email)
Analytics.trackUserCreated(user._id)
// ... etc
}
# Troubleshooting
## Route Not Matching
Symptoms: Your route handler isn't being called.
Checklist:
1. Is the method static?
2. Is the parameter type HttpResult?
3. Is the class in a scanned source module directory?
4. Is the event string exactly correct?
- Check spelling and spacing
- For GET requests, use hit or GET
- For regex routes, ensure the ~ prefix is present
5. Is another high-priority alert setting r.cancelled = true?
6. Check console for compilation errors
## Regex Route Issues
Symptoms: Regex routes not matching expected URLs.
Solutions:
- Remember to prefix with ~: ~on /path/(.*) hit
- Test your regex pattern separately first
- Use raw strings or escape backslashes: \\d not \d
- Spaces automatically become \s
- Verify with println r.matches to see captured groups
- Remember patterns are anchored (must match entire route)
## Multiple Handlers Conflict
Symptoms: Wrong handler responding to a route.
Solutions:
- Check handler priorities - higher numbers run first
- Use r.cancelled = true to stop subsequent handlers
- Check for overlapping regex patterns
- Use more specific routes before general ones
- Add logging to see execution order: println "Handler X executed"
## Missing Request Data
Symptoms: r.context.data is empty or missing expected fields.
Solutions:
- For POST/PUT: Verify Content-Type header is set correctly
- Form data: application/x-www-form-urlencoded
- JSON: application/json
- Multipart: multipart/form-data
- Check form field names match your access code
- Use println r.context.data to debug what's actually received
- For file uploads, ensure form encoding is multipart/form-data
## Response Already Committed
Symptoms: Error about response already being committed.
Solutions:
- Only call writeToClient() once per request
- Don't write after setRedirectUrl()
- Check that multiple handlers aren't both writing responses
- Use r.called to check if response was already sent
- Ensure high-priority middleware isn't writing then allowing continuation
# Summary
Spaceport's routing system provides a flexible, event-driven approach to handling HTTP requests. Key takeaways:
Fundamentals:
- Routes are handled using @Alert annotations on static methods
- Use on [route] hit for GET requests, on [route] [METHOD] for others
- The HttpResult object provides request context and response methods
Dynamic Routing:
- Prefix with ~ for regex patterns
- Captured groups available in r.matches
- Use regex constraints for type safety and validation
Middleware:
- Control execution order with priority values
- Use r.cancelled = true to stop the pipeline
- Separate concerns across multiple focused handlers
Best Practices: - Keep handlers focused and single-purpose - Validate input early and thoroughly - Handle errors gracefully with appropriate status codes - Organize routes logically across multiple classes - Document complex patterns and routing logic
With these patterns and practices, you can build sophisticated routing systems that are maintainable, performant, and easy to understand.
# See Also
- Alerts - Complete guide to Spaceport's event system
- Launchpad - Rendering HTML responses with templates
- Source Modules - Where to place route handler classes
- Transmissions - Handling interactive updates without page reloads
- Documents - Working with Spaceport's document database
SPACEPORT DOCS