SPACEPORT DOCS

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]

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