Router
Nova provides a minimal, zero-dependency HTTP router and a programmatic HTML generation engine, built upon Go's standard net/http package. It simplifies web development in Go by offering:
- Dynamic Route Parameters: Define routes with named parameters (e.g.,
/users/{id}). - Regex Validation in Paths: Optionally constrain parameters using regular expressions (e.g.,
/users/{id:[0-9]+}). - Programmatic HTML Generation: A fluent API for building HTML documents and elements directly in Go code, featuring automatic escaping and comprehensive tag support.
- Composable Middleware: Apply middleware globally, to groups of routes, or individually.
- Route Grouping: Organize related routes under a common path prefix and shared middleware.
- Subrouters: Mount independent router instances under specific path prefixes for modular applications.
- Standard
http.HandlerCompatibility: Integrates seamlessly with standard Go HTTP handlers and middleware. - Customizable 404/405 Handlers: Provide your own handlers for "Not Found" and "Method Not Allowed" responses.
- Context-Based Parameter Access: Retrieve URL parameters easily within your handlers.
- Enhanced Handler Signature (
HandlerFunc): Use with aResponseContextfor cleaner error handling, convenient response helpers, and automatic data binding/validation. - Rich
ResponseContextHelpers: Methods for sending JSON, programmatically generated HTML, text, and redirect responses. - Data Binding: Automatic binding of JSON and form data to Go structs.
- Struct Validation & Localization: Built-in validation for bound data using struct tags, with multi-language error messages (EN, ES, FR, DE, NL supported).
- Static File Serving: Serve static files and directories easily.
- Server Management: Integrated server utilities for graceful shutdown, live reloading, and configurable logging.
Table of Contents
- Getting Started
- Core Concepts
- Defining Routes
- Route Parameters
- Middleware
- Route Groups
- Subrouters
- Custom Error Handlers
- Serving Static Files
- Programmatic HTML Generation
- Overview
- The
HTMLElementInterface - Creating Elements (
nova.Div,nova.P, etc.) - Adding Content and Children
- Setting Attributes (
.Attr(),.Class(),.ID()) - Self-Closing Tags
- Text Nodes (
nova.Text()) - Building Full HTML Documents (
nova.Document()) - HTML Escaping
- Response Helpers (
ResponseContext) - JSON Responses
- HTML Responses (
ctx.HTML()) - Text Responses
- Redirects
- Accessing Underlying Writer/Request
- Content Negotiation (
WantsJSON) - Data Binding and Validation
- Server Management (
nova.Serve) - Full Example
1. Getting Started
Here's a minimal example to create a router, serve a simple HTML page, and start an HTTP server using Nova's integrated server utilities:
package main
import (
"log"
"net/http"
"os"
"github.com/xlc-dev/nova/nova"
)
func main() {
router := nova.NewRouter()
router.GetFunc("/", func(ctx *nova.ResponseContext) error {
// Use Nova's programmatic HTML generation
page := nova.Document(
nova.DocumentConfig{Title: "Hello Nova!"}, // Basic document configuration
nova.H1().Text("Welcome to the Nova Framework"),
nova.P().Text("This page is rendered programmatically using Nova's HTML engine."),
)
return ctx.HTML(http.StatusOK, page) // Send the HTML page as response
})
// Setup CLI for server commands (like --port, --watch, etc.)
cli, err := nova.NewCLI(&nova.CLI{
Name: "minimal-nova-app",
Version: "0.0.1",
Action: func(cliCtx *nova.Context) error {
// nova.Serve handles server lifecycle, logging, and live reload.
return nova.Serve(cliCtx, router)
},
})
if err != nil {
log.Fatal(err)
}
// Run the application
if err := cli.Run(os.Args); err != nil {
log.Fatal(err)
}
}
Build and run:
go build -o app
./app serve # Or simply ./app if 'serve' is the default action
Test the route in your browser or with curl:
curl http://localhost:8080/
# Output will be the rendered HTML page.
Core Concepts
The Router Struct
The nova.Router is the main component for defining routes and handling HTTP requests. You create one using nova.NewRouter(). It implements the standard http.Handler interface, so it can be passed directly to http.ListenAndServe or, more commonly, to Nova's Serve function for enhanced server management.
Key responsibilities:
- Registering routes with standard or enhanced handlers.
- Applying global middleware.
- Managing route groups and subrouters.
- Matching incoming requests to registered routes.
- Extracting URL parameters.
- Dispatching requests to appropriate handlers.
The ResponseContext Struct
When using enhanced handlers (HandlerFunc), your function receives a *nova.ResponseContext. This struct wraps the standard http.ResponseWriter and http.Request, providing a rich set of helper methods for:
- Sending various types of responses (JSON, HTML, text, redirects).
- Accessing URL parameters.
- Binding request data (JSON, form) to Go structs.
- Performing validation on bound data.
- Accessing the underlying
http.Requestandhttp.ResponseWriter.
The Group Struct
A nova.Group allows you to define a set of routes that share a common URL prefix and/or a specific stack of middleware. It's a lightweight helper created via router.Group("/prefix", optionalMiddleware...). Routes added to the group automatically inherit its prefix and middleware.
Route Matching
Nova matches routes based on the request path segments:
- The incoming request path is split by
/. - The router iterates through its registered routes. Subrouters are checked first if their base path (e.g.,
/admin) is a prefix of the request path. - For each route, it compares the request path segments with the route's pre-compiled segments.
- Literal segments must match exactly.
- Parameter segments (
{name}) match any value in that position and capture it. - Regex-constrained parameter segments (
{name:regex}) must match the provided regular expression (the regex is automatically anchored with^and$). - If a route pattern matches the path:
- If the HTTP method also matches, the handler (with middleware) is executed. URL parameters are added to the request context.
- If the HTTP method doesn't match, a 405 Method Not Allowed response is sent (using the custom handler if set).
- If no route pattern matches the path, a 404 Not Found response is sent (using the custom handler if set).
Defining Routes
Nova supports two primary handler signatures for defining routes.
Standard Handlers
These use the standard Go http.HandlerFunc signature: func(w http.ResponseWriter, r *http.Request). You can register them using HTTP method-specific functions on a Router or Group instance.
func listItemsHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "List of items")
}
func createItemHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "Item created")
}
router.Get("/items", listItemsHandler)
router.Post("/items", createItemHandler)
Other standard handler registration methods include Put, Patch, Delete, and the generic Handle(method, pattern, handler).
Enhanced Handlers (HandlerFunc)
For cleaner error handling, convenient response helpers, and automatic data binding/validation, Nova provides an enhanced handler signature: func(ctx *nova.ResponseContext) error.
func greetUserHandler(ctx *nova.ResponseContext) error {
userName := ctx.URLParam("name")
if userName == "" {
return ctx.Text(http.StatusBadRequest, "Name parameter is missing.")
}
greeting := fmt.Sprintf("Hello, %s!", userName)
return ctx.Text(http.StatusOK, greeting)
}
router.GetFunc("/greet/{name}", greetUserHandler)
Enhanced handler registration methods include GetFunc, PostFunc, PutFunc, PatchFunc, DeleteFunc, and the generic HandleFunc(method, pattern, handler). Returning an error from an enhanced handler will typically result in a 500 Internal Server Error response, though this can be customized.
Route Options
All route registration methods (Handle, HandleFunc, Get, GetFunc, etc.) accept optional *RouteOptions as the last argument. The RouteOptions struct itself is not defined by Nova; it's intended for users to define if they need to pass metadata associated with a route, for example, for OpenAPI documentation generation or other custom processing.
// Example: User-defined RouteOptions for OpenAPI
type MyRouteOptions struct {
Summary string
Description string
Tags []string
Deprecated bool
}
func getUserProfile(ctx *nova.ResponseContext) error { /* ... */ }
routeOpts := &MyRouteOptions{
Summary: "Get user profile",
Tags: []string{"users", "profile"},
}
router.GetFunc("/users/{id}/profile", getUserProfile, routeOpts) // Pass your custom options
Nova's router will store this pointer, but it's up to other parts of your application or third-party tools to interpret these options.
Route Parameters
Parameters allow parts of the URL path to be dynamic and captured for use in your handlers.
Basic Parameters
Define parameters using curly braces: {name}. The value captured for this segment will be available via the parameter name.
router.GetFunc("/store/{category}/items/{itemID}", func(ctx *nova.ResponseContext) error {
category := ctx.URLParam("category")
itemID := ctx.URLParam("itemID")
return ctx.Text(http.StatusOK, fmt.Sprintf("Store Category: %s, Item ID: %s", category, itemID))
})
Regex Constrained Parameters
You can add validation to a parameter by appending a colon and a Go regular expression within the curly braces: {name:regex}. The regex is automatically anchored with ^ and $ by the router. If the path segment does not match the regex, the route will not be considered a match.
// Matches /users/123 but not /users/abc
router.GetFunc("/users/{id:[0-9]+}", func(ctx *nova.ResponseContext) error {
userID := ctx.URLParam("id") // userID is guaranteed to be a sequence of digits
return ctx.Text(http.StatusOK, "Fetching user with numeric ID: "+userID)
})
// Matches /files/image.jpg but not /files/document.pdf if you want specific extensions
router.GetFunc("/files/{filename:[a-zA-Z0-9_]+\.(jpg|png|gif)}", func(ctx *nova.ResponseContext) error {
filename := ctx.URLParam("filename")
return ctx.Text(http.StatusOK, "Fetching image file: "+filename)
})
Accessing Parameters (URLParam)
- Inside an enhanced handler (
HandlerFunc): Usectx.URLParam("key")to retrieve the value of a captured parameter.
name := ctx.URLParam("name")
- Inside a standard handler (
http.HandlerFunc): You can userouter.URLParam(req, "key"). This requires having access to therouterinstance within the handler.
// Assuming 'router' is accessible, e.g., via a global variable or closure
// userID := router.URLParam(r, "id")
Using the ResponseContext method within an enhanced handler is generally preferred for cleaner code.
Middleware
Middleware provides a way to add cross-cutting concerns (like logging, authentication, compression, CORS) to your request handling pipeline.
- Type:
type Middleware func(http.Handler) http.Handler - Functionality: A middleware function takes an
http.Handler(the "next" handler in the chain) and returns a newhttp.Handler. This returned handler typically performs some action before and/or after calling theServeHTTPmethod of the "next" handler.
// Example: Logging Middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
log.Printf("Started %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r) // Call the next handler in the chain
log.Printf("Completed %s %s in %v", r.Method, r.URL.Path, time.Since(startTime))
})
}
Global Middleware (router.Use)
Middleware added via router.Use(mws...) applies to all routes handled by that router instance and any subrouters or groups created from it after the Use call. Middleware functions are applied in the order they are added.
router := nova.NewRouter()
router.Use(loggingMiddleware) // Applied first
router.Use(panicRecoveryMiddleware) // Applied second (wraps loggingMiddleware's output)
Group Middleware (group.Use)
Middleware added via group.Use(mws...) applies only to routes registered through that specific group instance. It runs after any global middleware defined on the parent router. You can also pass middleware directly when creating the group.
adminGroup := router.Group("/admin")
adminGroup.Use(requireAdminAuthMiddleware) // Applies only to routes in adminGroup
// Alternatively, pass middleware at group creation:
apiGroup := router.Group("/api", apiAuthMiddleware, rateLimitingMiddleware)
apiGroup.GetFunc("/data", func(ctx *nova.ResponseContext) error {
// This handler will have global, apiAuth, and rateLimiting middleware applied.
return ctx.JSON(http.StatusOK, map[string]string{"message": "Sensitive data"})
})
Execution Order
Middleware execution follows a standard "onion" model:
- Request comes in.
- Global middleware (added via
router.Use) executes. The last one added wraps the ones added before it, so it executes "first" on the way in. - Group middleware (added via
group.Useorrouter.Group) executes, similarly in a LIFO (Last-In, First-Out) wrapping order for that group. - The route's specific handler executes.
- The response travels back out through the middleware in the reverse order of execution on the way in (FIFO relative to addition).
Route Groups
Groups simplify managing routes that share a common URL prefix and/or a common set of middleware. Create a group using router.Group(prefix, optionalMiddleware...).
router := nova.NewRouter()
// API v1 routes
v1 := router.Group("/api/v1", apiVersionMiddleware("v1"))
v1.GetFunc("/users", listV1UsersHandler) // Path: /api/v1/users
v1.PostFunc("/products", createV1ProductHandler) // Path: /api/v1/products
// API v2 routes with additional authentication
v2AuthMiddleware := func(next http.Handler) http.Handler { /* ... */ return next }
v2 := router.Group("/api/v2", apiVersionMiddleware("v2"), v2AuthMiddleware)
v2.GetFunc("/users", listV2UsersHandler) // Path: /api/v2/users
// Public website section
web := router.Group("") // No prefix, can be used to apply middleware to a set of top-level routes
web.Use(commonWebMiddleware)
web.GetFunc("/about", aboutPageHandler) // Path: /about
Routes defined within a group automatically have the group's prefix prepended to their pattern.
Subrouters
Subrouters allow you to mount a completely separate nova.Router instance at a specific URL prefix. This is useful for modularizing large applications where different sections might have entirely different routing logic, middleware stacks, or even error handlers (though error handlers are inherited by default).
A subrouter created from a parent router inherits:
- The parent's
paramsKeyfor context. - A clone of the parent's global middleware stack at the time of subrouter creation. Subsequent changes to the parent's global middleware do not affect already created subrouters.
- The parent's
notFoundHandlerandmethodNotAllowedHandlerby default. These can be overridden on the subrouter. - The subrouter's
basePathwill be the parent'sbasePathjoined with the prefix provided during subrouter creation.
mainRouter := nova.NewRouter()
mainRouter.Use(globalLoggingMiddleware)
// Create a subrouter for an admin section
adminRouter := mainRouter.Subrouter("/admin")
adminRouter.Use(adminAuthenticationMiddleware) // Middleware specific to adminRouter
adminRouter.GetFunc("/dashboard", func(ctx *nova.ResponseContext) error {
// Path: /admin/dashboard
// Will have globalLoggingMiddleware and adminAuthenticationMiddleware applied.
return ctx.HTML(http.StatusOK, nova.Document(nova.DocumentConfig{Title: "Admin Dashboard"}, nova.H1().Text("Admin Panel")))
})
adminRouter.GetFunc("/users", func(ctx *nova.ResponseContext) error {
// Path: /admin/users
return ctx.Text(http.StatusOK, "Admin User List")
})
// Another subrouter for a public API
publicApiRouter := mainRouter.Subrouter("/public-api")
publicApiRouter.GetFunc("/version", func(ctx *nova.ResponseContext) error {
// Path: /public-api/version
// Will only have globalLoggingMiddleware applied.
return ctx.JSON(http.StatusOK, map[string]string{"version": "1.0"})
})
// mainRouter.ServeHTTP will delegate to adminRouter or publicApiRouter if the path matches their basePath.
When a request comes in, the main router first checks if the request path matches the basePath of any of its subrouters. If a match is found, the request is delegated to that subrouter's ServeHTTP method.
Custom Error Handlers
You can customize the responses for 404 (Not Found) and 405 (Method Not Allowed) errors by providing your own http.Handler.
Not Found (404)
This handler is invoked when no route matches the requested URL path.
func customNotFoundHandler(w http.ResponseWriter, r *http.Request) {
// Using ResponseContext for convenience, even in a standard handler
rc := nova.ResponseContext{W: w, R: r} // Router field will be nil, but basic W/R ops are fine
errorPage := nova.Document(
nova.DocumentConfig{Title: "404 - Page Not Found"},
nova.H1().Text("Oops! Page Not Found"),
nova.P(nova.Text(fmt.Sprintf("The page you requested (%s) could not be found.", r.URL.Path))),
nova.A("/", nova.Text("Go to Homepage")),
)
// Manually set status and content type for HTML
w.Header().Set("Content-Type", "text/html; charset=utf-f-8")
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, errorPage.Render())
}
router := nova.NewRouter()
router.SetNotFoundHandler(http.HandlerFunc(customNotFoundHandler))
If using an enhanced handler with ResponseContext is preferred for error pages, you'd typically handle this within your application logic or a dedicated error handling middleware if a route isn't found by Nova's router. The SetNotFoundHandler expects a standard http.Handler.
Method Not Allowed (405)
This handler is invoked when a route pattern matches the URL path, but not the HTTP method (e.g., a POST request to a GET-only route).
func customMethodNotAllowedHandler(w http.ResponseWriter, r *http.Request) {
// It's good practice to set the 'Allow' header with permitted methods.
// This information isn't directly available from Nova's router to this handler by default.
// You might need to determine allowed methods based on your routing setup if you want to be precise.
w.Header().Set("Allow", "GET, POST") // Example
rc := nova.ResponseContext{W: w, R: r}
rc.JSONError(http.StatusMethodNotAllowed,
fmt.Sprintf("Method %s is not allowed for the resource %s.", r.Method, r.URL.Path))
}
router := nova.NewRouter()
router.SetMethodNotAllowedHandler(http.HandlerFunc(customMethodNotAllowedHandler))
Serving Static Files
Nova allows you to serve static files (like CSS, JavaScript, images) from an fs.FS (such as one created from embed.FS or os.DirFS) under a specified URL prefix.
The router.Static(urlPathPrefix string, subFS fs.FS) method sets up routes for GET and HEAD requests to serve files from subFS under the given urlPathPrefix.
import (
"embed"
"io/fs"
"log"
"net/http"
"github.com/xlc-dev/nova/nova"
)
//go:embed assets/*
var embeddedAssets embed.FS
func main() {
router := nova.NewRouter()
// Create a sub-filesystem for the 'public' directory within 'assets'.
// This is necessary if your files are in a subdirectory of the embedded FS.
// If 'assets' itself is the root of your static files, you can use 'embeddedAssets' directly.
publicFilesFS, err := fs.Sub(embeddedAssets, "assets/public")
if err != nil {
log.Fatalf("Failed to create sub FS for static files: %v", err)
}
// Serve files from the 'publicFilesFS' (i.e., 'assets/public' directory)
// under the URL prefix '/static'.
// Example: A file at 'assets/public/css/style.css' will be accessible at '/static/css/style.css'.
// Example: A file at 'assets/public/images/logo.png' will be accessible at '/static/images/logo.png'.
router.Static("/static", publicFilesFS)
// Example route that might use these static files
router.GetFunc("/", func(ctx *nova.ResponseContext) error {
page := nova.Document(
nova.DocumentConfig{
Title: "Static Files Example",
HeadExtras: []nova.HTMLElement{
nova.StyleSheet("/static/css/main.css"),
},
},
nova.H1().Text("Page with Static Assets"),
nova.Img("/static/images/banner.jpg", "Banner Image"),
nova.Script("/static/js/app.js").Attr("defer","true"),
)
return ctx.HTML(http.StatusOK, page)
})
// rest of your server setup (CLI, nova.Serve)
}
This setup uses http.FileServer and http.StripPrefix internally to serve the files efficiently.
Programmatic HTML Generation
Nova includes a powerful and fluent API for generating HTML programmatically within your Go code. This allows for type-safe construction of HTML structures without relying on traditional template files.
Overview
The system is built around the HTMLElement interface. You construct HTML by creating Element instances (representing tags like <div>, <p>, etc.) or textNode instances, and composing them. Numerous helper functions (e.g., nova.Div(), nova.P(), nova.Img()) simplify element creation.
The HTMLElement Interface
This is the core of the HTML generation system:
type HTMLElement interface {
Render() string
}
Any type that implements this interface can be rendered as part of an HTML structure and can be passed to ctx.HTML(). Both *nova.Element and *nova.HTMLDocument (and textNode) implement this interface.
Creating Elements (nova.Div, nova.P, etc.)
Nova provides helper functions for most standard HTML tags. These functions return an *nova.Element, allowing for method chaining.
import "github.com/xlc-dev/nova/nova"
myDiv := nova.Div()
myParagraph := nova.P()
myImage := nova.Img("path/to/image.jpg", "Alternative text") // src, alt
myLink := nova.A("https://example.com", nova.Text("Click me")) // href, content
A comprehensive list of helpers includes Html, Head, Body, TitleEl, Meta, LinkTag, Script, StyleTag, H1-H6, Table, Form, Input, Button, and many more covering semantic, form, and media elements.
Adding Content and Children
Elements can have direct text content or child HTMLElements.
- Direct Text Content: Use the
.Text(string) *Elementmethod. This content is HTML-escaped during rendering (except for<script>and<style>tags).
titleHeader := nova.H1().Text("Page Title")
// Renders: <h1>Page Title</h1>
- Child Elements: Pass
HTMLElements as variadic arguments to the element creation function (e.g.,nova.Div(child1, child2)) or use the.Add(children ...HTMLElement) *Elementmethod.
container := nova.Div(
nova.H2().Text("Subtitle"),
nova.P().Text("Some paragraph text."),
)
// Or using .Add():
anotherContainer := nova.Div().Add(
nova.Span().Text("Part 1"),
nova.Span().Text("Part 2"),
)
Setting Attributes (.Attr(), .Class(), .ID())
Attributes are set using fluent methods on an *nova.Element:
.Attr(key, value string) *Element: Sets a generic attribute..Class(class string) *Element: Sets theclassattribute..ID(id string) *Element: Sets theidattribute..Style(style string) *Element: Sets thestyleattribute..BoolAttr(key string, present bool) *Element: Adds a boolean attribute (e.g.,<input disabled>) ifpresentis true. The attribute is rendered askey="key". Ifpresentis false, the attribute is removed if it exists.
styledDiv := nova.Div().
ID("main-content").
Class("container theme-dark").
Style("border: 1px solid red; padding: 10px;").
Attr("data-custom", "my-value")
inputField := nova.Input("text").BoolAttr("required", true).Attr("placeholder", "Enter name")
// Renders: <input type="text" required="required" placeholder="Enter name" />
Self-Closing Tags
Some elements, like <img>, <br>, <hr>, <input>, <meta>, and <link>, are self-closing. The helper functions for these tags create elements that render correctly with />.
separator := nova.Hr() // Renders: <hr />
iconLink := nova.LinkTag().Attr("rel", "icon").Attr("href", "/favicon.ico") // Renders: <link rel="icon" href="/favicon.ico" />
charsetMeta := nova.MetaCharset("UTF-16") // Renders: <meta charset="UTF-16" />
Text Nodes (nova.Text())
For adding plain text that should be HTML-escaped when used as a child of another element, use nova.Text(content string) HTMLElement. This returns a textNode which implements HTMLElement.
paragraphWithMixedContent := nova.P(
nova.Text("This is "),
nova.Strong().Text("bold"),
nova.Text(" text, and this is "),
nova.Em().Text("italic"),
nova.Text("."),
)
// Renders: <p>This is <strong>bold</strong> text, and this is <em>italic</em>.</p>
Building Full HTML Documents (nova.Document())
To create a complete HTML5 page, use the nova.Document(config DocumentConfig, bodyContent ...HTMLElement) *HTMLDocument function. It takes a nova.DocumentConfig for head customizations and variadic HTMLElements for the body content. The returned *HTMLDocument also implements HTMLElement.
// In your handler:
page := nova.Document(
nova.DocumentConfig{
Lang: "en-US",
Title: "My Awesome App",
Description: "A fantastic application built with Nova.",
Keywords: "nova, web, go, framework",
Author: "Awesome Dev",
HeadExtras: []nova.HTMLElement{ // Add custom links, scripts, meta tags to head
nova.StyleSheet("/css/theme.css"),
nova.Script("/js/app.js").Attr("defer", "defer"), // Note: Script tag is not self-closing
nova.MetaNameContent("robots", "index, follow"),
},
},
// Body content starts here
nova.Header(
nova.H1().Text("Welcome to My Awesome App!"),
),
nova.Main(
nova.P().Text("This is the main content of the page, demonstrating a full document structure."),
nova.Img("/images/logo.png", "App Logo").Class("logo"),
),
nova.Footer(
nova.P().Text("© 2025 My Company"),
),
)
// err := ctx.HTML(http.StatusOK, page)
DocumentConfig allows customization of:
Lang:<html>lang attribute (default "en").Title:<title>tag content (default "Document").Charset:<meta charset>(default "utf-8").Viewport:<meta name="viewport">(default "width=device-width, initial-scale=1").Description,Keywords,Author: For respective meta tags (omitted if empty).HeadExtras: Slice ofHTMLElements to add to the<head>section (e.g., additional stylesheets, scripts, meta tags).
The nova.Document() function constructs the <!DOCTYPE html>, <html>, <head> (with specified meta tags and title), and <body> structure for you.
HTML Escaping
By default, text content set via .Text() or nova.Text() and attribute values are HTML-escaped using html.EscapeString to prevent XSS vulnerabilities.
Exception: The direct content of <script> and <style> elements (set via .Text() or passed to InlineScript() / StyleTag()) is not escaped. This allows you to embed raw JavaScript and CSS directly.
inlineJS := nova.InlineScript("if (a < b && c > d) { console.log('Condition met'); }")
// Renders: <script>if (a < b && c > d) { console.log('Condition met'); }</script> (content is raw)
inlineCSS := nova.StyleTag("body > p { font-weight: bold; color: #333; }")
// Renders: <style>body > p { font-weight: bold; color: #333; }</style> (content is raw)
Be cautious when embedding user-provided data into inline scripts or styles.
Response Helpers (ResponseContext)
These methods are available on *nova.ResponseContext within HandlerFunc (enhanced handlers).
JSON Responses
JSON(statusCode int, data any) error: Encodesdatato JSON and sends it. SetsContent-Type: application/json.JSONError(statusCode int, message string) error: Sends a standardized JSON error:{"error": "message"}.
type Item struct { ID string `json:"id"`; Name string `json:"name"` }
func getItemHandler(ctx *nova.ResponseContext) error {
item := Item{ID: "item123", Name: "Example Item"}
return ctx.JSON(http.StatusOK, item)
}
HTML Responses (ctx.HTML())
HTML(statusCode int, content HTMLElement) error: Sends an HTML response. SetsContent-Type: text/html; charset=utf-8.
The content argument must be a type that implements the nova.HTMLElement interface (like *nova.Element, *nova.HTMLDocument, or a custom type that has a Render() string method).
// In a handler:
func serveSimpleHTMLPage(ctx *nova.ResponseContext) error {
// Using Nova's programmatic HTML builders
myPageContent := nova.Div().
Class("container").
Add(
nova.H1().Text("Hello from Nova!"),
nova.P().Text("This HTML was generated in Go using Nova's fluent API."),
nova.A("https://example.com", nova.Text("Learn more about Nova")),
)
// If you need a full document structure:
fullDoc := nova.Document(
nova.DocumentConfig{Title: "My Nova Page"},
myPageContent, // Add the div as body content
)
return ctx.HTML(http.StatusOK, fullDoc) // Send the full document
}
Text Responses
Text(statusCode int, text string) error: Sends a plain text response. SetsContent-Type: text/plain; charset=utf-8.
func healthCheckHandler(ctx *nova.ResponseContext) error {
return ctx.Text(http.StatusOK, "Server is healthy and running.")
}
Redirects
Redirect(statusCode int, url string) error: Sends an HTTP redirect. Sets theLocationheader and writes the status code.
Common status codes: http.StatusMovedPermanently (301), http.StatusFound (302), http.StatusTemporaryRedirect (307).
func oldPathHandler(ctx *nova.ResponseContext) error {
// Permanent redirect from an old path to a new one
return ctx.Redirect(http.StatusMovedPermanently, "/new-path-for-this-resource")
}
func loginRequiredHandler(ctx *nova.ResponseContext) error {
// Temporary redirect to login page if user is not authenticated
return ctx.Redirect(http.StatusFound, "/login?returnTo="+ctx.Request().URL.Path)
}
Accessing Underlying Writer/Request
Request() *http.Request: Returns the underlying*http.Requestinstance.Writer() http.ResponseWriter: Returns the underlyinghttp.ResponseWriterinstance.
These are useful for advanced scenarios or when integrating with third-party libraries that expect these standard Go types.
func advancedHandler(ctx *nova.ResponseContext) error {
// Access raw request for specific header not covered by helpers
apiKey := ctx.Request().Header.Get("X-API-Key")
if apiKey == "" {
return ctx.JSONError(http.StatusUnauthorized, "API Key required")
}
// Use raw writer for something like streaming a response
// ctx.Writer().Header().Set("Content-Type", "application/octet-stream")
// flusher, ok := ctx.Writer().(http.Flusher)
// if !ok { /* handle error */ }
// ... stream data ...
return nil // Or an error if streaming fails
}
Content Negotiation (WantsJSON)
WantsJSON() bool: Returnstrueif the request'sContent-TypeorAcceptheader suggests a preference for JSON (i.e., contains "application/json"). This is useful for creating endpoints that can serve multiple content types based on client preference.
func versatileDataHandler(ctx *nova.ResponseContext) error {
data := map[string]string{"id": "data123", "value": "Some important information"}
if ctx.WantsJSON() {
return ctx.JSON(http.StatusOK, data)
}
// Fallback to HTML representation
htmlContent := nova.Document(
nova.DocumentConfig{Title: "Data Details"},
nova.H1().Text("Data Item: "+data["id"]),
nova.Dl( // Definition List example
nova.Dt().Text("ID"),
nova.Dd().Text(data["id"]),
nova.Dt().Text("Value"),
nova.Dd().Text(data["value"]),
),
)
return ctx.HTML(http.StatusOK, htmlContent)
}
Data Binding and Validation
Nova simplifies handling incoming request data (JSON, forms) and validating it using struct tags.
Binding Request Data
Use ResponseContext methods within an enhanced handler (HandlerFunc):
ctx.Bind(v any) error: Automatically detectsContent-Type(supports "application/json" and URL-encoded form data) and binds the request data to the provided structv(which must be a pointer).ctx.BindJSON(v any) error: Specifically for binding JSON request bodies tov.ctx.BindForm(v any) error: Specifically for parsing URL-encoded form data (fromr.Form) and binding it tov.- For form binding, it supports
string,bool(recognizes "on", "true", "1" as true, otherwise false if field is bool), numeric types (int*,uint*,float*), and[]string(from comma-separated values in a single field or multiple form fields with the same name). - Field name matching uses
jsonstruct tags (e.g.,json:"user_name"), falling back to the struct field names if nojsontag is present or if the tag is"-".
type CreateUserInput struct {
Username string `json:"username"` // Used for both JSON and form field name matching
Email string `json:"email"`
Age int `json:"age,omitempty"`
Tags []string `json:"tags"` // For forms, can be 'tag1,tag2' or multiple 'tags=tag1&tags=tag2'
}
func handleCreateUser(ctx *nova.ResponseContext) error {
var input CreateUserInput
if err := ctx.Bind(&input); err != nil { // Binds JSON or Form data
return ctx.JSONError(http.StatusBadRequest, "Invalid input data: "+err.Error())
}
// Process input (e.g., save to database)
log.Printf("User data received: %+v", input)
return ctx.JSON(http.StatusCreated, input)
}
Validating Structs
ctx.BindValidated(v any) error: This is the most convenient method. It first binds the request data (JSON or form) to the structv(pointer) and then validatesvusing rules defined in struct tags.- If validation fails, it returns a
nova.ValidationErrorstype (which is[]error). TheError()method ofValidationErrorsreturns a semicolon-separated string of all validation messages. - The
error:"custom message"tag on a struct field allows you to specify a custom error message for any validation failure on that field, overriding the default localized message for that specific field's validation. - If a field in a struct is intended to be required, ensure its
jsontag does not includeomitempty. If such a field is its zero value after binding (e.g., empty string forstring, 0 forint), it will trigger a "required" validation error.
type SignupInput struct {
FullName string `json:"fullName" minlength:"2" maxlength:"50"`
Email string `json:"email" format:"email"` // Implicitly required if no 'omitempty'
Age int `json:"age,omitempty" min:"18" max:"120"` // Optional due to omitempty
Password string `json:"password" format:"password" error:"Your password is too weak, please choose a stronger one."`
}
func handleSignup(ctx *nova.ResponseContext) error {
var input SignupInput
if err := ctx.BindValidated(&input); err != nil {
// err will be of type nova.ValidationErrors
return ctx.JSONError(http.StatusBadRequest, err.Error())
}
// Process valid input (e.g., create user account)
log.Printf("Signup successful for: %+v", input)
return ctx.JSON(http.StatusOK, map[string]string{"message": "Signup successful!"})
}
Validation is recursive: if a struct field is itself a struct or a pointer to a struct, validateStruct will be called on it. For slices of structs, each element in the slice is validated.
Supported Validation Tags
required: (Implicit) If a field'sjsontag does not containomitemptyand the field has its zero value after binding.minlength:"<value>": Minimum string length (forstringtype).maxlength:"<value>": Maximum string length (forstringtype).min:"<value>": Minimum numeric value (forint*,uint*,float*types).max:"<value>": Maximum numeric value (forint*,uint*,float*types).pattern:"<regex>": String must match the Go regular expression (forstringtype).enum:"<val1>|<val2>|...": String must be one of the specified pipe-separated values (forstringtype).format:"<type>": Predefined format validation for strings. Supported types:email: Validates usingmail.ParseAddress.url: Validates usingurl.ParseRequestURI, ensuring scheme and host are present.uuid: Validates against RFC-4122 v4 UUID format.date-time: Validates againsttime.RFC3339format (e.g.,2023-10-26T10:00:00Z).date: Validates againstYYYY-MM-DDformat (e.g.,2023-10-26).time: Validates againstHH:MM:SSformat (e.g.,10:00:00).password: Basic check, ensures string length is at least 8 characters.phone: Basic international phone number pattern^[+]?[1-9][,,-()]{7,15}$.alphanumeric: Contains only A-Z, a-z, 0-9.alpha: Contains only A-Z, a-z.numeric: Contains only 0-9.multipleOf:"<value>": Number must be a multiple of the value (for numeric types).minItems:"<value>": Minimum number of items in a slice/array.maxItems:"<value>": Maximum number of items in a slice/array.uniqueItems:"true": All items in a slice/array must be unique. (Compares underlying values).error:"<custom_message>": Overrides the default/localized validation error message for any validation rule that fails on this specific field.
Localization
Validation error messages are automatically localized based on the Accept-Language HTTP header in the request.
- Supported languages (defined in
validationMessagesmap): English (en), Spanish (es), French (fr), German (de), Dutch (nl). - English (
en) serves as the fallback if a requested language or a specific message key within a language is not found. - The
detectLanguageinternal function parses theAccept-Languageheader (simplified parsing) to pick the best available match from the supported languages.
Server Management (nova.Serve)
Nova provides a Serve(ctx *nova.Context, router http.Handler) error function to simplify server startup, management, and add features like live reloading and configurable logging. It's typically used as the action for a nova.CLI command.
The *nova.Context argument provides access to parsed command-line flags and configuration.
Key features of nova.Serve:
- Server Startup: Listens on a host and port. These can be configured via
*nova.Context(typically from CLI flags). host(string, default: "localhost", e.g.,--host 0.0.0.0)port(int, default: 8080, e.g.,--port 3000)- Graceful Shutdown: Catches
SIGINT(Ctrl+C) andSIGTERMsignals to shut down the HTTP server gracefully within a 5-second timeout. - Live Reloading (Hot Reload):
- Enable by setting
watch: truein the*nova.Context(e.g., via a--watchCLI flag). - Monitors file changes in the current working directory (or a specified directory).
- Watched file extensions are configurable via an
extensionsstring in*nova.Context(comma-separated, default: ".go", e.g.,--extensions .go,.html,.css). - When a change in a watched file is detected,
nova.Serverebuilds the application (go build -o <current_executable_path> .) and then re-executes the new binary, effectively restarting the server with the new code. - Configurable Logging (slog):
- Log output format can be set via
log_formatin*nova.Context(string, "text" or "json", default: "text", e.g.,--log-format json). - Log level can be set via
log_levelin*nova.Context(string, "debug", "info", "warn"/"warning", "error", default: "info", e.g.,--log-level debug). - This configures the global
slog.Defaultlogger. - Verbose Mode:
- Setting
verbose: truein*nova.Context(e.g., via a--verboseCLI flag) enables more detailed logging from theServefunction itself, such as file watcher activity and recompilation commands.
// Example usage within main.go
// Assuming cli is a *nova.CLI instance
cli.Action = func(cliCtx *nova.Context) error {
router := setupMyApplicationRouter() // Your function to get the configured router
slog.Info("Application starting...") // Uses slog, configured by nova.Serve
return nova.Serve(cliCtx, router)
}
Full Example
This example demonstrates routing, middleware, programmatic HTML generation, static file serving, data binding with validation, and server management via nova.Serve and nova.CLI.
package main
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"log/slog"
"net/http"
"os"
"time"
"github.com/xlc-dev/nova/nova"
)
//go:embed static/*
var embeddedStaticFiles embed.FS
// Middleware
func requestLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Use slog for structured logging, which nova.Serve can configure
slog.Info("Request received", "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr)
next.ServeHTTP(w, r)
slog.Info("Request completed", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
})
}
func simpleAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Auth-Token") != "secret-token" {
// Use ResponseContext for consistent error responses
rc := nova.ResponseContext{W: w, R: r}
rc.JSONError(http.StatusUnauthorized, "Unauthorized: Missing or invalid X-Auth-Token")
return
}
ctx := context.WithValue(r.Context(), "user", "AuthenticatedUser")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// HTML Page Components (Example)
func pageLayout(config nova.DocumentConfig, bodyContent ...nova.HTMLElement) nova.HTMLElement {
defaultConfig := nova.DocumentConfig{
Lang: "en",
Title: "Nova App",
Charset: "UTF-8",
Viewport: "width=device-width, initial-scale=1.0",
HeadExtras: []nova.HTMLElement{
nova.StyleSheet("/assets/css/style.css"), // Path to static CSS
nova.Script("/assets/js/main.js").Attr("defer", "true"),
},
}
// Override defaults with provided config
if config.Title != "" {
defaultConfig.Title = config.Title
}
if config.Lang != "" {
defaultConfig.Lang = config.Lang
}
// Append, don't overwrite, HeadExtras
defaultConfig.HeadExtras = append(defaultConfig.HeadExtras, config.HeadExtras...)
allBodyContent := []nova.HTMLElement{
nova.Header(
nova.Nav(nova.A("/", nova.Text("Home")), nova.Text(" | "), nova.A("/contact", nova.Text("Contact"))).Class("main-nav"),
).Class("site-header"),
}
allBodyContent = append(allBodyContent, bodyContent...)
allBodyContent = append(allBodyContent,
nova.Footer(nova.P().Text(fmt.Sprintf("© %d Nova Example Inc.", time.Now().Year()))).Class("site-footer"),
)
return nova.Document(defaultConfig, allBodyContent...)
}
// Handlers
func handleHomepage(ctx *nova.ResponseContext) error {
slog.Info("Handling homepage request")
content := nova.Main(
nova.H1().Text("Welcome to the Nova Framework Showcase!"),
nova.P().Text("This page is dynamically rendered using Nova's programmatic HTML engine."),
nova.Img("/assets/images/banner.png", "Nova Banner").Style("max-width:100%; height:auto;"),
)
return ctx.HTML(http.StatusOK, pageLayout(nova.DocumentConfig{Title: "Homepage"}, content))
}
func handleContactPage(ctx *nova.ResponseContext) error {
slog.Info("Handling contact page request")
contactForm := nova.Form(
nova.H2().Text("Contact Us"),
nova.P(
nova.Label(nova.Text("Your Name: ")).Attr("for", "name"),
nova.TextInput("name").ID("name").Attr("required", "true"),
),
nova.P(
nova.Label(nova.Text("Your Email: ")).Attr("for", "email"),
nova.EmailInput("email").ID("email").Attr("required", "true"),
),
nova.P(
nova.Label(nova.Text("Message: ")).Attr("for", "message"),
nova.Textarea().ID("message").Attr("name", "message").Attr("rows", "5").Attr("required", "true"),
),
nova.SubmitButton("Send Message"),
).Attr("method", "POST").Attr("action", "/contact-submit")
content := nova.Main(contactForm)
return ctx.HTML(http.StatusOK, pageLayout(nova.DocumentConfig{Title: "Contact Us"}, content))
}
type ContactFormInput struct {
Name string `json:"name" minlength:"2" error:"Name must be at least 2 characters."`
Email string `json:"email" format:"email"`
Message string `json:"message" minlength:"10" error:"Message is too short."`
}
func handleContactSubmit(ctx *nova.ResponseContext) error {
var input ContactFormInput
if err := ctx.BindValidated(&input); err != nil {
slog.Warn("Contact form validation failed", "errors", err.Error())
// For a real app, you'd re-render the form with errors.
// Here, we'll just return a JSON error for simplicity.
return ctx.JSONError(http.StatusBadRequest, "Validation failed: "+err.Error())
}
slog.Info("Contact form submitted", "name", input.Name, "email", input.Email)
thankYouMessage := nova.Main(
nova.H1().Text("Thank You!"),
nova.P().Text("Your message has been received. We will get back to you shortly."),
nova.A("/", nova.Text("Return to Homepage")),
)
return ctx.HTML(http.StatusOK, pageLayout(nova.DocumentConfig{Title: "Message Sent"}, thankYouMessage))
}
func handleApiGetData(ctx *nova.ResponseContext) error {
user, _ := ctx.Request().Context().Value("user").(string)
slog.Info("API: GetData requested", "authenticated_user", user)
data := map[string]any{
"message": "This is protected data from the API.",
"timestamp": time.Now().Format(time.RFC3339),
"currentUser": user,
}
return ctx.JSON(http.StatusOK, data)
}
func main() {
// Router Setup
router := nova.NewRouter()
// Global Middleware
router.Use(requestLoggingMiddleware)
// Serve static files from embedded 'static' directory under '/assets' URL path,
// e.g., /assets/css/style.css
staticFilesRoot, err := fs.Sub(embeddedStaticFiles, "static")
if err != nil {
log.Fatalf("Failed to create sub FS for static files: %v", err)
}
router.Static("/assets", staticFilesRoot)
// Public Routes
router.GetFunc("/", handleHomepage)
router.GetFunc("/contact", handleContactPage)
router.PostFunc("/contact-submit", handleContactSubmit)
// API Routes with Authentication
apiGroup := router.Group("/api/v1")
apiGroup.Use(simpleAuthMiddleware)
apiGroup.GetFunc("/data", handleApiGetData)
// CLI Setup
cliApp, err := nova.NewCLI(&nova.CLI{
Name: "NovaFullApp",
Version: "1.0.0",
Description: "A full example application using the Nova framework.",
Action: func(cliCtx *nova.Context) error {
// This action is called when 'myfullapp serve' (or just 'myfullapp') is run.
// nova.Serve will use cliCtx for port, host, watch, log settings.
slog.Info("Starting Nova application server...", "version", cliCtx.App.Version)
return nova.Serve(cliCtx, router)
},
// Add more commands here if needed
})
if err != nil {
log.Fatalf("Failed to initialize CLI: %v", err)
}
// Run the CLI application
if err := cliApp.Run(os.Args); err != nil {
slog.Error("Application exited with error", "error", err)
os.Exit(1)
}
}