redirect

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package redirect parses and resolves _redirects files.

The _redirects file format, popularised by Netlify and Cloudflare Pages, defines rules for URL redirects, rewrites, and error responses based on incoming request paths.

File Format

A _redirects file contains one rule per line in the format:

<source> <target> [<status>]

For example:

/old-path        /new-path
/blog/:year/*    /posts/:year/:splat  302
/app/*           /index.html          200
/gone            /                    410

The status code determines how the match is handled:

  • 3xx (or omitted): redirects the client to the target URL (default 301, or 302 with WithDefaultStatus).
  • 200: rewrites the request, serving the target path internally without redirecting the client.
  • 4xx / 5xx: answers the request immediately with that HTTP status code.

Query parameters from the original request are preserved and appended to the target URL unless the target explicitly defines query parameters.

Placeholders and Security

Placeholders (such as ":name" and trailing "*"/":splat") captured from the source path are expanded into the target path, query string, and fragment separately, with component-appropriate escaping. Placeholder values containing ".." cannot escape the target directory path.

Forced Rules and Shadowing

A trailing "!" on the status code (e.g. "301!") marks the rule as forced (Rule.Force). When Handler is configured with WithExists, unforced rules are skipped if the wrapped handler already serves a static file at the requested path. Without WithExists, rules always apply regardless of existing files.

Proxying

Rewrites (status 200) to absolute URLs (e.g. "https://api.example.com/:splat") are permitted when parsed with WithProxying. In Handler, proxied requests are forwarded to the handler supplied via WithProxy. If no proxy handler is configured, Handler returns an error wrapping ErrProxy.

Usage

The package supports two workflows:

  • HTTP middleware: Handler and NewHandler wrap an existing net/http.Handler and automatically handle redirects, rewrites, errors, proxying, and shadowing.
  • Manual resolution: Parse reads rules into a []Rule slice, and Resolve returns a *Resolved result for callers that want to handle redirection and rewriting directly.
Example

Example demonstrates parsing and resolving redirect.

package main

import (
	"fmt"
	"log"
	"net/url"
	"strings"

	"thde.io/rulefiles/redirect"
)

// file contains example redirect rules.
const file = `
/old-path            /new-path
/blog/:year/*        /posts/:year/:splat   302
/gone                /                     410
/app/*               /index.html           200
`

func main() {
	rules, err := redirect.Parse(strings.NewReader(file))
	if err != nil {
		log.Fatal(err)
	}

	for _, request := range []string{"/old-path", "/blog/2026/hello/world?utm=1", "/gone", "/app/deep/link", "/about"} {
		from, err := url.Parse(request)
		if err != nil {
			log.Fatal(err)
		}

		resolved, err := redirect.Resolve(rules, from)
		if err != nil {
			log.Fatal(err)
		}
		if resolved == nil {
			fmt.Printf("%s: no rule\n", request)

			continue
		}
		fmt.Printf("%s: %d %s\n", request, resolved.Rule.Status, resolved.To)
	}

}
Output:
/old-path: 301 /new-path
/blog/2026/hello/world?utm=1: 302 /posts/2026/hello/world?utm=1
/gone: 410 /
/app/deep/link: 200 /index.html
/about: no rule

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrSyntax reports malformed rule syntax.
	ErrSyntax = rulefile.ErrSyntax

	// ErrUnsupported reports a line that Netlify or Cloudflare Pages accepts but that this package does not implement,
	// such as matching on a country or a query parameter.
	ErrUnsupported = rulefile.ErrUnsupported
)
View Source
var ErrProxy = errors.New("no proxy configured")

ErrProxy reports a destination that is fetched from another host, resolved by a handler that was not given a proxy with WithProxy.

Functions

func Handler

func Handler(rules []Rule, next http.Handler, opts ...HandlerOption) http.Handler

Handler returns a handler that resolves the rules against the URL of a request and acts on the first rule that matches: a status of 200 rewrites the request and passes it to next, a status of 400 and above answers it with that status, anything else redirects to the destination. A request no rule matches is passed to next as it was received.

Rule.Force is only honoured when WithExists is given; without it every rule applies, whether or not next serves a file of the same name. A destination Resolved.Proxy reports needs WithProxy to be fetched.

The rules are resolved once. A rewrite is not matched against the rules again, so a rule may rewrite to a path another rule redirect.

Example

ExampleHandler demonstrates wrapping a handler with redirect rules.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"

	"thde.io/rulefiles/redirect"
)

// file contains example redirect rules.
const file = `
/old-path            /new-path
/blog/:year/*        /posts/:year/:splat   302
/gone                /                     410
/app/*               /index.html           200
`

func main() {
	rules, err := redirect.Parse(strings.NewReader(file))
	if err != nil {
		slog.Error("reading the redirect rules", "error", err)

		return
	}

	site := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		//nolint:gosec // Echo request path for example output.
		_, _ = fmt.Fprintln(w, "serving "+r.URL.Path)
	})

	handler := redirect.Handler(rules, site)

	for _, request := range []string{"/old-path", "/app/deep/link", "/gone", "/about"} {
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, request, http.NoBody))

		fmt.Printf("%s: %d %s\n", request, rec.Code, strings.TrimSpace(rec.Header().Get("Location")+rec.Body.String()))
	}

}
Output:
/old-path: 301 /new-path<a href="/new-path">Moved Permanently</a>.
/app/deep/link: 200 serving /index.html
/gone: 410 Gone
/about: 200 serving /about

func NewHandler

func NewHandler(r io.Reader, next http.Handler, opts ...Option) (http.Handler, error)

NewHandler reads the rules of a _redirects file from r and returns Handler(rules, next). Use Parse and Handler to combine the options of the parser with those of the handler.

Types

type HandlerOption

type HandlerOption func(*handlerOptions)

HandlerOption configures a Handler. It settles what the rules of a file leave to the caller: whether a rule may shadow a file next serves, and how a destination of another host is fetched.

func WithErrorHandler

func WithErrorHandler(fn func(w http.ResponseWriter, r *http.Request, err error)) HandlerOption

WithErrorHandler answers a request whose destination could not be built, or that resolves to a proxy without WithProxy. It defaults to a 500.

func WithExists

func WithExists(exists func(r *http.Request) bool) HandlerOption

WithExists honours Rule.Force: a rule written without a trailing "!" is skipped while exists reports that next serves the path of the request itself, so that a redirect does not shadow a file of the same name. A later rule still matches such a request.

Without it every rule applies, forced or not, as the handler cannot know what next serves.

Example

ExampleWithExists demonstrates honouring the force flag of a rule.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"

	"thde.io/rulefiles/redirect"
)

func main() {
	const file = `
/about       /about-us      301
/contact     /contact-us    301!
`

	rules, err := redirect.Parse(strings.NewReader(file))
	if err != nil {
		slog.Error("reading the redirect rules", "error", err)

		return
	}

	site := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		//nolint:gosec // Echo request path for example output.
		_, _ = fmt.Fprintln(w, "serving "+r.URL.Path)
	})

	// files holds the paths the site serves itself.
	files := map[string]bool{"/about": true, "/contact": true}
	handler := redirect.Handler(rules, site, redirect.WithExists(func(r *http.Request) bool {
		return files[r.URL.Path]
	}))

	for _, request := range []string{"/about", "/contact"} {
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, request, http.NoBody))

		fmt.Printf("%s: %d %s\n", request, rec.Code, strings.TrimSpace(rec.Header().Get("Location")+rec.Body.String()))
	}

}
Output:
/about: 200 serving /about
/contact: 301 /contact-us<a href="/contact-us">Moved Permanently</a>.

func WithProxy

func WithProxy(proxy http.Handler) HandlerOption

WithProxy serves a destination Resolved.Proxy reports with proxy, in a copy of the request whose URL is the absolute destination of the rule. Only rules parsed with WithProxying resolve to such a destination; without a proxy it is answered as an error wrapping ErrProxy.

A net/http/httputil.ReverseProxy fetches the URL of the request it is given:

redirect.WithProxy(&httputil.ReverseProxy{
	Rewrite: func(pr *httputil.ProxyRequest) { pr.Out.URL = pr.In.URL },
})
Example

ExampleWithProxy demonstrates fetching a rewrite from another host.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"strings"

	"thde.io/rulefiles/redirect"
)

func main() {
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		//nolint:gosec // Echo request path for example output.
		_, _ = fmt.Fprintln(w, "the backend answered "+r.URL.Path)
	}))
	defer backend.Close()

	rules, err := redirect.Parse(
		strings.NewReader("/api/*  "+backend.URL+"/:splat  200\n"),
		redirect.WithProxying(),
	)
	if err != nil {
		slog.Error("reading the redirect rules", "error", err)

		return
	}

	proxy := &httputil.ReverseProxy{
		Rewrite: func(pr *httputil.ProxyRequest) { pr.Out.URL = pr.In.URL },
	}

	rec := httptest.NewRecorder()
	redirect.Handler(rules, http.NotFoundHandler(), redirect.WithProxy(proxy)).
		ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/users", http.NoBody))

	fmt.Printf("%d %s", rec.Code, rec.Body)

}
Output:
200 the backend answered /v1/users

type Option

type Option func(*options)

Option configures the parser.

func WithDefaultStatus

func WithDefaultStatus(status int) Option

WithDefaultStatus sets the default redirect status. Defaults to 301, as on Netlify. Cloudflare Pages uses 302.

func WithExactTrailingSlash

func WithExactTrailingSlash() Option

WithExactTrailingSlash matches trailing slashes exactly. By default a trailing slash is ignored on both sides, as on Netlify.

Example

ExampleWithExactTrailingSlash demonstrates exact slash matching.

package main

import (
	"fmt"
	"log"
	"net/url"
	"strings"

	"thde.io/rulefiles/redirect"
)

func main() {
	const file = "/docs /docs/\n"

	for _, opts := range [][]redirect.Option{nil, {redirect.WithExactTrailingSlash(), redirect.WithDefaultStatus(302)}} {
		rules, err := redirect.Parse(strings.NewReader(file), opts...)
		if err != nil {
			log.Fatal(err)
		}

		for _, request := range []string{"/docs", "/docs/"} {
			from, err := url.Parse(request)
			if err != nil {
				log.Fatal(err)
			}

			resolved, err := redirect.Resolve(rules, from)
			if err != nil {
				log.Fatal(err)
			}
			if resolved == nil {
				fmt.Printf("%s: no rule\n", request)

				continue
			}
			fmt.Printf("%s: %d %s\n", request, resolved.Rule.Status, resolved.To)
		}
	}

}
Output:
/docs: 301 /docs/
/docs/: 301 /docs/
/docs: 302 /docs/
/docs/: no rule

func WithProxying

func WithProxying() Option

WithProxying allows rewrites to other hosts. Fetching the destination is left to the caller.

Example

ExampleWithProxying demonstrates proxying rewrites to URLs.

package main

import (
	"fmt"
	"log"
	"net/url"
	"strings"

	"thde.io/rulefiles/redirect"
)

func main() {
	const file = `
/api/*    https://backend.example.com/:splat   200
/app/*    /index.html                          200
`

	rules, err := redirect.Parse(strings.NewReader(file), redirect.WithProxying())
	if err != nil {
		log.Fatal(err)
	}

	for _, request := range []string{"/api/v1/users", "/app/deep/link"} {
		from, err := url.Parse(request)
		if err != nil {
			log.Fatal(err)
		}

		resolved, err := redirect.Resolve(rules, from)
		if err != nil {
			log.Fatal(err)
		}

		if resolved.Proxy() {
			fmt.Printf("%s: fetch %s\n", request, resolved.To)

			continue
		}
		fmt.Printf("%s: serve %s\n", request, resolved.To)
	}

}
Output:
/api/v1/users: fetch https://backend.example.com/v1/users
/app/deep/link: serve /index.html

type Resolved

type Resolved struct {
	// Rule is the first rule that matched the request.
	Rule Rule
	// To is the target URL.
	To *url.URL
}

Resolved holds the matched redirect result.

func Resolve

func Resolve(rules []Rule, from *url.URL) (*Resolved, error)

Resolve returns the redirect of the first rule matching from, in the order the rules are declared. It returns nil if no rule matches.

The status of the matched rule says what to do with the destination: - a 200 rewrites the request to it - a 400 and above answers the request with that error - anything else redirects to it

The caller needs to act on the result. A rewrite Resolved.Proxy reports is fetched from another host rather than served from the site.

Example

ExampleResolve demonstrates handling matched redirect rules.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"thde.io/rulefiles/redirect"
)

// file contains example redirect rules.
const file = `
/old-path            /new-path
/blog/:year/*        /posts/:year/:splat   302
/gone                /                     410
/app/*               /index.html           200
`

func main() {
	rules, err := redirect.Parse(strings.NewReader(file))
	if err != nil {
		log.Fatal(err)
	}

	serve := func(w http.ResponseWriter, r *http.Request) {
		//nolint:gosec // Echo request path for example output.
		_, _ = fmt.Fprintln(w, "serving "+r.URL.Path)
	}

	handler := func(w http.ResponseWriter, r *http.Request) {
		resolved, err := redirect.Resolve(rules, r.URL)
		if err != nil {
			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

			return
		}
		if resolved == nil {
			serve(w, r)

			return
		}

		switch status := resolved.Rule.Status; {
		case status == http.StatusOK:
			// Rewrite request URL.
			r.URL = resolved.To

			serve(w, r)
		case status >= 400:
			http.Error(w, http.StatusText(status), status)
		default:
			//nolint:gosec // Destination is from static rules.
			http.Redirect(w, r, resolved.To.String(), status)
		}
	}

	for _, request := range []string{"/old-path", "/app/deep/link", "/gone", "/about"} {
		rec := httptest.NewRecorder()
		handler(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, request, http.NoBody))

		fmt.Printf("%s: %d %s%s", request, rec.Code, rec.Header().Get("Location"), rec.Body)
	}

}
Output:
/old-path: 301 /new-path<a href="/new-path">Moved Permanently</a>.

/app/deep/link: 200 serving /index.html
/gone: 410 Gone
/about: 200 serving /about

func (*Resolved) Proxy

func (r *Resolved) Proxy() bool

Proxy reports whether the destination is to be fetched from another host rather than served from the site. It is only ever true for rules parsed with WithProxying, which allows a rewrite to target an absolute URL, and is false for a nil *Resolved.

type Rule

type Rule struct {
	// Source is the original path pattern.
	Source string
	// Target is the destination path or URL.
	Target string
	// Status is the HTTP status code.
	Status int
	// Force overrides existing static files.
	Force bool
	// contains filtered or unexported fields
}

Rule is a single redirect rule.

func Parse

func Parse(r io.Reader, opts ...Option) ([]Rule, error)

Parse reads the rules of a _redirects file. Empty lines and comments starting with "#" are ignored. Errors of all lines are reported together.

By default, the rules behave as they do on Netlify. Use Option arguments to configure their behaviour.

func (Rule) Destination

func (ru Rule) Destination(captures map[string]string, from *url.URL) (*url.URL, error)

Destination builds the URL a request to from is sent to. Query parameters of the request are kept unless the target defines them itself.

Example

ExampleRule_Destination demonstrates expanding path placeholders.

package main

import (
	"fmt"
	"log"
	"net/url"
	"strings"

	"thde.io/rulefiles/redirect"
)

func main() {
	rules, err := redirect.Parse(strings.NewReader("/blog/:slug /posts/:slug?from=:slug\n"))
	if err != nil {
		log.Fatal(err)
	}
	rule := rules[0]

	for _, request := range []string{"/blog/hello", "/blog/a%20b%26c", "/about"} {
		from, err := url.Parse(request)
		if err != nil {
			log.Fatal(err)
		}

		captures, ok := rule.Match(from.Path)
		if !ok {
			fmt.Printf("%s: %s does not match\n", request, rule.Source)

			continue
		}

		to, err := rule.Destination(captures, from)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%s: slug %q -> %s\n", request, captures["slug"], to)
	}

}
Output:
/blog/hello: slug "hello" -> /posts/hello?from=hello
/blog/a%20b%26c: slug "a b&c" -> /posts/a%20b&c?from=a+b%26c
/about: /blog/:slug does not match

func (Rule) Match

func (ru Rule) Match(reqPath string) (map[string]string, bool)

Match reports whether reqPath matches the rule and returns the captured placeholder values.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL