Skip to content

Allow plugins to decorate an existing core route (route overrides) - #2961

Open
malinthaprasan wants to merge 3 commits into
wso2:mainfrom
malinthaprasan:platform-modze
Open

Allow plugins to decorate an existing core route (route overrides)#2961
malinthaprasan wants to merge 3 commits into
wso2:mainfrom
malinthaprasan:platform-modze

Conversation

@malinthaprasan

@malinthaprasan malinthaprasan commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Purpose

A wrapper embedding platform-api as a library can add routes and chain middleware, but cannot change what an existing core endpoint returns. The only options are forking the handler or shadowing the route, and ServeMux panics on a duplicate pattern.

Goals

Let a plugin decorate one existing core route: its Wrap receives the original core handler as next. Declared explicitly (so it is auditable), fails startup on a pattern core doesn't register, and leaves the route's scopes untouched.

Approach

Three commits, each buildable on its own:

  1. internal/router — a Router interface (*http.ServeMux satisfies it) and a Recorder that records registrations instead of serving them. Core handlers' RegisterRoutes now take router.Router; the pdk.Plugin / plugin.Plugin contracts keep *http.ServeMux, so no plugin or wrapper changes.
  2. pdk/override.goRouteOverride, RouteOverrideProvider, and the capture helpers Invoke / WriteCaptured, re-exported from platform.
  3. Server wiring — core routes are recorded, plugins declare their claims, then installCoreRoutes validates them and installs each recorded route on the mux, wrapped where claimed.

Registering under the same pattern is what keeps r.PathValue correct inside the core handler. A nested mux behind a "/" catch-all was rejected: it costs plugin routes their 405, and makes next the whole mux, so a decorator rewriting r.URL.Path would re-dispatch the request to a different endpoint after auth and scope enforcement had already run against the original path.

Constraints, documented rather than worked around: an override never changes the route's scopes; one plugin per route; patterns match as exact strings; streaming routes can't be overridden (the capture writer is deliberately neither a Flusher nor a Hijacker). A plugin route colliding with a core pattern is now a startup error naming the pattern instead of a ServeMux panic.

User stories

  • As a wrapper author, I add a field to an existing endpoint's response without forking its handler or duplicating its route.
  • As a reviewer, every decorated route is declared explicitly and logged at startup, and a stale pattern stops the server instead of quietly doing nothing.

Documentation

N/A — extension surface only, no user-facing API change. The contract is documented in the doc comments on pdk.RouteOverride, Invoke, and WriteCaptured.

Automation tests

  • Unit tests — 28 new. internal/router 100.0% statement coverage, pdk 96.4%, installCoreRoutes 100.0%. Cover ordering and deferred errors in the recorder, capture/write semantics, and every startup-failure path (unknown pattern, duplicate claims, nil Wrap, empty pattern, plugin/core collision).
  • Integration tests — none new. Manually verified against a running server with a wrapper declaring two overrides: enriched response on the overridden route, list route unchanged, core's 404 passed through byte for byte, 405 preserved, and a bad pattern refusing startup.

Security checks

  • Followed secure coding standards? yes
  • Ran FindSecurityBugs plugin? N/A — Go module, no Java source; go vet clean.
  • Confirmed no keys/passwords/tokens/secrets committed? yes

An override cannot widen access. Required scopes are keyed by OpenAPI path/method and are untouched, so a decorated route keeps the requirement it had. A decorator cannot re-route a request either — the handler for the pattern is already selected before Wrap runs, so rewriting the path changes nothing about what executes. The contract documents that a decorator must read the organization from request context, never from request input. Every malformed or unmatched override aborts startup rather than being skipped.

Samples

// Declare the claim. Pattern is matched as an exact string against what core
// registers — a wrong version or wildcard name fails startup.
func (p *MyPlugin) RouteOverrides() []platform.RouteOverride {
    return []platform.RouteOverride{
        {Pattern: "GET /api/v0.9/gateways/{gatewayId}", Wrap: p.enrichGateway()},
    }
}

// next is the original core handler, registered under the same pattern — so the
// mux has already resolved r.PathValue("gatewayId") by the time this runs.
func (p *MyPlugin) enrichGateway() platform.Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            res := platform.Invoke(next, r) // run core, capture its response

            if res.Status != http.StatusOK {
                platform.WriteCaptured(w, res) // pass errors through untouched
                return
            }

            var base platformapi.GatewayResponse
            if err := json.Unmarshal(res.Body, &base); err != nil {
                trackingID := util.NewToken()
                p.deps.Logger.Error("gateway decode failed",
                    "trackingId", trackingID, "error", err)
                httputil.WriteJSON(w, http.StatusInternalServerError, map[string]any{
                    "error": "internal_error", "tracking_id": trackingID,
                })
                return
            }

            httputil.WriteJSON(w, http.StatusOK,
                toCloudGateway(base, p.environmentFor(base.Id)))
        })
    }
}

A decorator that only observes the outcome should not use Invoke at all — wrap the http.ResponseWriter to record the status instead. Buffering a whole response to read a status code costs memory for nothing, and Invoke's writer is deliberately not a Flusher/Hijacker.

Related PRs

None.

Test environment

Go 1.26.5, macOS. go build ./..., go build ./cmd/main.go, go vet, and the test suite all clean; each of the three commits was checked out and built independently.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The PR adds a recorder-based router abstraction, updates core handlers to register through it, and introduces plugin route overrides. Plugins can decorate or replace core handlers, with validation, response capture utilities, deferred installation, and startup error handling.

Core routing and plugin route overrides

Layer / File(s) Summary
Router recorder and handler registration
platform-api/internal/router/*, platform-api/internal/handler/*
Core route registration uses router.Router and Recorder, preserving route order while detecting invalid or duplicate registrations.
Route override and response contracts
platform-api/pdk/override.go, platform-api/platform/override.go, platform-api/internal/plugin/plugin.go
New override provider types and response capture/write helpers support plugin decorators and are re-exported through the platform package.
Plugin override collection and validation
platform-api/internal/server/plugins.go, platform-api/internal/server/external_plugin.go
Plugin initialization collects overrides, forwards external plugin providers, and rejects malformed or conflicting claims.
Deferred installation and override execution
platform-api/internal/server/server.go, platform-api/internal/server/overrides.go, platform-api/internal/server/*_test.go
Core routes are recorded before plugin initialization, then installed onto http.ServeMux with validated decorators and startup conflict handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: anugayan, krishanx92, renuka-fernando, arshardh

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: enabling plugins to decorate existing core routes.
Description check ✅ Passed The description follows the template well and fills the required sections with clear purpose, goals, approach, tests, security, samples, and environment.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@malinthaprasan malinthaprasan changed the title WIP Allow plugins to decorate an existing core route (route overrides) Jul 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@platform-api/pdk/override.go`:
- Around line 139-160: Update WriteCaptured to clear each destination header key
before adding its captured values, ensuring captured headers replace existing
upstream values rather than append duplicates. Preserve the existing
Content-Length exclusion and status/body handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 047bb071-e076-4a83-819e-fe588316c3f2

📥 Commits

Reviewing files that changed from the base of the PR and between f4de02b and 7f1fda0.

📒 Files selected for processing (31)
  • platform-api/internal/handler/api.go
  • platform-api/internal/handler/api_deployment.go
  • platform-api/internal/handler/api_key.go
  • platform-api/internal/handler/apikey_user.go
  • platform-api/internal/handler/application.go
  • platform-api/internal/handler/auth_login.go
  • platform-api/internal/handler/gateway.go
  • platform-api/internal/handler/gateway_internal.go
  • platform-api/internal/handler/llm.go
  • platform-api/internal/handler/llm_apikey.go
  • platform-api/internal/handler/llm_deployment.go
  • platform-api/internal/handler/llm_proxy_apikey.go
  • platform-api/internal/handler/mcp.go
  • platform-api/internal/handler/mcp_deployment.go
  • platform-api/internal/handler/organization.go
  • platform-api/internal/handler/project.go
  • platform-api/internal/handler/secret.go
  • platform-api/internal/handler/subscription_handler.go
  • platform-api/internal/handler/subscription_plan_handler.go
  • platform-api/internal/handler/websocket.go
  • platform-api/internal/plugin/plugin.go
  • platform-api/internal/router/router.go
  • platform-api/internal/router/router_test.go
  • platform-api/internal/server/external_plugin.go
  • platform-api/internal/server/overrides.go
  • platform-api/internal/server/overrides_test.go
  • platform-api/internal/server/plugins.go
  • platform-api/internal/server/server.go
  • platform-api/pdk/override.go
  • platform-api/pdk/override_test.go
  • platform-api/platform/override.go

Comment on lines +139 to +160
func WriteCaptured(w http.ResponseWriter, res *CapturedResponse) {
if res == nil {
return
}
dst := w.Header()
for k, vs := range res.Header {
if http.CanonicalHeaderKey(k) == "Content-Length" {
continue
}
for _, v := range vs {
dst.Add(k, v)
}
}
status := res.Status
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
if len(res.Body) > 0 {
_, _ = w.Write(res.Body)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | grep -F 'platform-api/pdk/override.go' || true

echo "== file outline =="
if [ -f platform-api/pdk/override.go ]; then
  ast-grep outline platform-api/pdk/override.go || true
  echo "== relevant lines =="
  cat -n platform-api/pdk/override.go | sed -n '1,240p'
fi

echo "== tests for WriteCaptured/captureWriter =="
rg -n "WriteCaptured|captureWriter|CapturedResponse|Content-Type" platform-api/pdk -S || true

echo "== static verifier of header merging semantics in Go stdlib =="
cat > /tmp/header_check.go <<'EOF'
package main

import (
	"net/http"
	"fmt"
)
func main() {
	dst := http.Header{}
	dst.Set("Content-Type", "old")
	for k := range dst {
		dst.Del(k)
	}
	dst.Add("Content-Type", "new")
	fmt.Println(dst.Get("Content-Type"), dst["Content-Type"])
}
EOF
go run /tmp/header_check.go

Repository: wso2/api-platform

Length of output: 10349


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant tests =="
cat -n platform-api/pdk/override_test.go | sed -n '1,180p'

echo "== writeCaptured call sites =="
rg -n "WriteCaptured\\(" platform-api -S

echo "== programmatic evidence: Go Add preserves existing header while Get returns first value =="
cat > /tmp/header_merge.go <<'EOF'
package main

import (
	"fmt"
	"net/http"
)

func main() {
	dst := http.Header{}
	dst.Set("Content-Type", "old")
	dst.Add("Content-Type", "new")
	fmt.Printf("Get=%q\n", dst.Get("Content-Type"))
	fmt.Printf("Values=%v\n", dst["Content-Type"])
}
EOF
go run /tmp/header_merge.go

Repository: wso2/api-platform

Length of output: 7040


Clear destination headers before adding captured headers.

dst.Add(k, v) appends captured header values to any existing values for the same key, so clients can receive duplicate headers instead of the captured response overriding upstream defaults. Clear the key before adding each captured header value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/pdk/override.go` around lines 139 - 160, Update WriteCaptured to
clear each destination header key before adding its captured values, ensuring
captured headers replace existing upstream values rather than append duplicates.
Preserve the existing Content-Length exclusion and status/body handling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant