From 38ef7b3fc93f264ab8a4c1a03852fcbcd07c03ec Mon Sep 17 00:00:00 2001 From: Bruno Carvalho Date: Sat, 13 Jun 2026 11:23:39 -0300 Subject: [PATCH] feat(runtime): support dynamic fragment routes --- cmd/gowdk/route_report.go | 4 + docs/compiler/generated-output.md | 12 +- docs/engineering/architecture.md | 13 +- .../m7-ssr-hybrid-implementation-plan.md | 42 ++++ docs/engineering/release-plan.md | 27 +-- docs/language/guards.md | 8 +- docs/language/hybrid.md | 24 ++- docs/language/partials.md | 20 +- docs/language/ssr.md | 16 +- docs/product/m7-ssr-hybrid-spec.md | 49 +++++ docs/product/requirements.md | 12 +- docs/product/roadmap.md | 7 +- docs/reference/cli.md | 16 +- docs/reference/deployment.md | 5 +- docs/reference/diagnostic-codes.md | 3 +- docs/reference/hooks.md | 38 +++- docs/reference/routing.md | 45 +++-- examples/partials/patients-fragment.page.gwdk | 6 + internal/appgen/appgen_test.go | 191 ++++++++++++++++++ internal/appgen/ir.go | 1 + internal/appgen/ir_test.go | 38 ++++ internal/appgen/source.go | 8 + internal/appgen/source_backend.go | 40 +++- internal/appgen/source_backend_app.go | 5 +- internal/appgen/source_fragments.go | 126 +++++++++++- internal/appgen/source_guards.go | 2 +- internal/appgen/types.go | 1 + internal/appgen/validate_actions.go | 2 +- internal/buildgen/build.go | 85 ++++++++ internal/buildgen/build_report_test.go | 45 +++++ internal/compiler/route_bindings.go | 8 + internal/compiler/route_bindings_test.go | 10 +- internal/compiler/routes.go | 22 +- internal/compiler/validate.go | 2 +- internal/compiler/validate_page.go | 16 +- internal/compiler/validate_test.go | 145 ++++++++++++- internal/diagnostics/registry.go | 3 +- internal/gwdkanalysis/ir_builder.go | 4 + internal/gwdkir/ir.go | 1 + internal/lang/sitemap.go | 4 + internal/source/source.go | 134 ++++++++++++ internal/source/source_test.go | 37 ++++ runtime/app/app_test.go | 28 +++ runtime/app/backend.go | 48 ++++- runtime/guard/guard_test.go | 65 ++++++ runtime/guard/response.go | 120 +++++++++++ 46 files changed, 1398 insertions(+), 140 deletions(-) create mode 100644 docs/engineering/m7-ssr-hybrid-implementation-plan.md create mode 100644 docs/product/m7-ssr-hybrid-spec.md create mode 100644 runtime/guard/response.go diff --git a/cmd/gowdk/route_report.go b/cmd/gowdk/route_report.go index d8d38ff5..320f05fc 100644 --- a/cmd/gowdk/route_report.go +++ b/cmd/gowdk/route_report.go @@ -51,6 +51,8 @@ type endpointBindingJSON struct { Method string `json:"method"` Route string `json:"route"` Cache string `json:"cache,omitempty"` + DynamicParams []string `json:"dynamicParams,omitempty"` + RouteParams []routeParamJSON `json:"routeParams,omitempty"` Guards []string `json:"guards,omitempty"` CSRF bool `json:"csrf,omitempty"` PageID string `json:"pageId"` @@ -163,6 +165,8 @@ func endpointsJSON(bindings []compiler.EndpointBinding) []endpointBindingJSON { Method: binding.Method, Route: binding.Route, Cache: binding.Cache, + DynamicParams: append([]string(nil), binding.DynamicParams...), + RouteParams: routeParamsJSON(binding.RouteParams), Guards: append([]string(nil), binding.Guards...), CSRF: binding.CSRF, PageID: binding.PageID, diff --git a/docs/compiler/generated-output.md b/docs/compiler/generated-output.md index 522f3211..a51f5268 100644 --- a/docs/compiler/generated-output.md +++ b/docs/compiler/generated-output.md @@ -143,10 +143,12 @@ Implemented today: - Generated apps can return partial fragment responses from action handlers for `X-GOWDK-Partial` requests and standalone `fragment Name GET "/path" "#target" { ... }` routes. Standalone fragment - bodies can expand known components at app generation time. If the source - package exports `func Name(context.Context) (response.Response, error)`, the - generated fragment handler calls that request-time hook instead of the static - fallback. + routes can be concrete or dynamic; dynamic fragment route params are matched + with `runtime/route` and exposed to hooks through `runtime/app.Params(ctx)` + and `runtime/app.TypedParams(ctx)`. Standalone fragment bodies can expand + known components at app generation time. If the source package exports + `func Name(context.Context) (response.Response, error)`, the generated + fragment handler calls that request-time hook instead of the static fallback. - Generated app action endpoint extraction rejects direct file inputs and multipart `g:post` forms. Uploads belong in user-owned API/server handlers. - `internal/compiler` resolves same-package action, API, fragment, and SSR load @@ -255,7 +257,7 @@ requests, applies `http.Server` defaults of `ReadHeaderTimeout: 5s`, `MaxHeaderBytes: 1 MiB`, maps extensionless routes to nested `index.html` files, and does not list directories. It exposes `/_gowdk/health` and adds `X-GOWDK-App`, `X-GOWDK-Module`, and `X-GOWDK-Instance-ID` headers to responses. -Request-time action/API dispatch registers generated backend routes with +Request-time action/API/fragment dispatch registers generated backend routes with `runtime/app.BackendRouter` and passes the router hook into `runtime/app`. Generated action/API body caps default to 1 MiB and use `Build.BodyLimits` overrides when configured. Older separate action/API hook fields remain a diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 46f3b2e9..4c8e66c4 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -158,8 +158,8 @@ manifest report (`internal/lang/testdata/manifest_golden`). | `internal/lsp` | Language Server Protocol bridge for diagnostics, formatting, completions, and hover. | Tools | Dependency-free stdio server implemented with baseline and open-project completions plus hover for known language tokens and open-project symbols. | | `internal/project` | Load project-level config, module source groups, build targets, and future source roots. | Compiler | SPA `gowdk.config.go` subset implemented for build discovery, output, and `Build.Targets`; project-level CLI commands require this config or an explicit `--config` file before compiling `.gwdk` code. | | `internal/compiler` | Validate manifests and coordinate compilation metadata. | Compiler | Render-mode, duplicate identity, redundant component implementation, component Go contract, saved default `go {}` package type-checking with sibling Go files, route shape, duplicate route param, duplicate route pattern, route-method, required page-view validation, default `go {}` backend endpoint binding fallback, and `go/packages`-backed backend binding implemented. CLI route/endpoint reports now convert through `internal/gwdkir.Program`. | -| `internal/buildgen` | Emit route-derived spa HTML files for build-time pages and SSR render artifacts. | Compiler | Disk builds, memory builds, incremental SPA builds, and SSR artifact planning consume `internal/gwdkir.Program`. Initial simple page, literal build data, imported Go build data calls, literal dynamic path expansion, component expansion, partial runtime asset emission, default JS island asset emission, component-level non-CSS asset emission, component-level WASM island asset emission, page-level `go client {}` WASM mount asset emission, concrete and dynamic SSR page rendering with declared `load {}` placeholders, route manifest emission, asset manifest emission, OpenAPI report emission, mandatory build report emission, identical-output write skipping, and incremental changed-page spa rendering implemented. | -| `internal/appgen` | Emit generated Go app source for embedded spa output and request-time routes. | Compiler | Auto route planning consumes `internal/gwdkir.Program`, backend adapter planning uses typed appgen IR, and generated app Go files are assembled with `go/ast`/`go/printer` before `go/format`. Generates `go.mod`, `main.go`, copied spa assets, thin `runtime/app` server wiring, `runtime/app.BackendRouter` registrations for feature-bound action/API routes, 501 stubs for missing/unsupported handlers, POST redirect and partial fragment action handlers backed by `runtime/form`, `runtime/response`, `runtime/validation`, and `addons/partial`, form input decoders, concrete and dynamic SSR route handlers backed by `runtime/route`, declared SSR load path calls with redirect/error-page handling through `addons/ssr`, shared request-time guard checks through `runtime/guard`, generated `gowdk_go/` packages for default `go {}` and `go ssr {}` blocks, addon `GoBlockConsumer` Go files, split backend apps, command/query contract exposure metadata in adapter IR including runtime roles, identical-output write skipping, stale embedded spa cleanup, and can invoke `go build` for local binaries or Go `js/wasm` artifacts. | +| `internal/buildgen` | Emit route-derived spa HTML files for build-time pages and SSR render artifacts. | Compiler | Disk builds, memory builds, incremental SPA builds, and SSR artifact planning consume `internal/gwdkir.Program`. Initial simple page, literal build data, imported Go build data calls, literal dynamic path expansion, component expansion, partial runtime asset emission, default JS island asset emission, component-level non-CSS asset emission, component-level WASM island asset emission, page-level `go client {}` WASM mount asset emission, concrete and dynamic SSR page rendering with declared `load {}` placeholders, route manifest emission, asset manifest emission, OpenAPI report emission, mandatory build report emission with cache-policy and request-time skip events, identical-output write skipping, and incremental changed-page spa rendering implemented. | +| `internal/appgen` | Emit generated Go app source for embedded spa output and request-time routes. | Compiler | Auto route planning consumes `internal/gwdkir.Program`, backend adapter planning uses typed appgen IR, and generated app Go files are assembled with `go/ast`/`go/printer` before `go/format`. Generates `go.mod`, `main.go`, copied spa assets, thin `runtime/app` server wiring, `runtime/app.BackendRouter` registrations for feature-bound action/API/fragment routes, 501 stubs for missing/unsupported handlers, POST redirect and partial fragment action handlers backed by `runtime/form`, `runtime/response`, `runtime/validation`, and `addons/partial`, form input decoders, concrete and dynamic standalone fragment routes, concrete and dynamic SSR route handlers backed by `runtime/route`, declared SSR load path calls with redirect/error-page handling through `addons/ssr`, shared request-time guard checks through `runtime/guard`, generated `gowdk_go/` packages for default `go {}` and `go ssr {}` blocks, addon `GoBlockConsumer` Go files, split backend apps, command/query contract exposure metadata in adapter IR including runtime roles, identical-output write skipping, stale embedded spa cleanup, and can invoke `go build` for local binaries or Go `js/wasm` artifacts. | | `internal/clientrt` | Emit client runtime for partial updates and static-first SPA navigation. | Runtime | First partial form enhancement runtime emits lifecycle hooks, target/swap request headers, swaps, focus restoration, loading state metadata, island remounts, and page-level `go client {}` remounts after SPA navigation. | | `runtime/render` | Core rendering engine used by static output, actions, partials, and SSR. | Runtime | Renderer and generated-code builder implemented; expression text writes escape by default. | | `runtime/component` | Generated component runtime contract. | Runtime | Initial component interface implemented. | @@ -170,7 +170,7 @@ manifest report (`internal/lang/testdata/manifest_golden`). | `runtime/validation` | Validation result and errors for actions. | Runtime | Initial result model implemented. | | `runtime/response` | HTML, redirect, fragment, and JSON response envelopes. | Runtime | Initial response model implemented. | | `runtime/asset` | Asset manifest resolution. | Runtime | Initial manifest helper implemented. | -| `runtime/route` | Runtime route matching for generated request-time routes. | Runtime | Dynamic route matcher for first-slice generated SSR routes implemented. | +| `runtime/route` | Runtime route matching for generated request-time routes. | Runtime | Dynamic route matcher for first-slice generated SSR and standalone fragment routes implemented. | | `runtime/app` | Shared generated app HTTP server. | Runtime | Serves embedded spa files, identity headers, health checks, asset manifest counts, optional generated 404/500 pages, no-JS cookie acknowledgement, server-side cookie notice hiding, generated CSRF token injection for POST forms, request-time panic boundaries, and generated action/API/fragment/SSR callback hooks. | | `runtime/contracts` | Typed contract registry and in-process dispatch. | Runtime | First runtime slice implemented for queries, commands, backend-owned domain and integration events, presentation events, jobs, metadata, stable observation names and labels for logs/metrics/traces, local command-buffered event dispatch, event-envelope capture/replay with stable IDs, dependency-free outbox/broker/presentation-fanout/event-source/seen-store interfaces, command event sinks, an event worker loop with ack/nack plus context cancellation and optional post-ack deduplication windows, a dependency-free file outbox adapter, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, and dependency-free SSE presentation fanout adapter. Concrete Redis Streams, Redis TTL seen-store, NATS, and WebSocket adapters are nested optional modules. Split worker/cron generation, retry backoff policy, and managed deployment recipes remain planned. | | `addons/static` | Build-time static page output. | Addon | Capability boundary implemented; build-time output uses `runtime/render` through the compiler view renderer. | @@ -281,11 +281,12 @@ mux.HandleFunc("GET /dashboard", ssr.RenderDashboard) mux.HandleFunc("GET /api/patients", api.PatientsIndex) ``` -The current code can plan route metadata for CLI reports and can emit SPA HTML files, CSS assets from compile-time processors and discovered page CSS inputs, stylesheet links, page-aware processor stylesheet selections, `gowdk-routes.json`, `gowdk-assets.json`, `gowdk-build-report.json`, the partial-update client runtime, and generated island runtime assets when needed for simple build-time pages with explicit or discovered component and layout files. It expands the first literal `paths {}` subset for dynamic SPA routes, binds those route params plus literal `build {}` data or imported Go build data into the current SPA `view {}` interpolation context, resolves typed component props/state contracts from Go module imports, runs state init functions at build time, and composes SPA page layouts through each layout's single ``; literal `build {}` string values can also interpolate current route params. It parses the supported action body subset and can generate SPA POST redirect handlers plus form input decoders, required-field validation wrappers, typed same-package action decoder glue, user action/API calls, CSRF token wiring when `Build.CSRF.Enabled` is set, and partial fragment responses for concrete page routes through `addons/partial`. `gowdk build --app` can also generate concrete and dynamic SSR routes for pages with request-time full-page behavior, with dynamic route matching, generated typed route-param bindings backed by `runtime/route`, and `load {}` execution for declared fields through `addons/ssr`; generated SSR load functions can return safe local redirects, generated SSR load failures can render optional `500.html`, and generated apps can render optional `404.html` for not-found responses. Generated guarded SSR, action, API, and fragment routes require `GOWDKGuardRegistry` for custom guard IDs and `GOWDKAuthProvider` for native `role:`/`permission:` RBAC guard IDs, fail Go compilation when required backing hooks are missing, and run declared guards through `runtime/guard` before user logic. Generated SSR, action, and API lanes also recover panics before response headers are written as no-store HTTP 500 responses without exposing panic values. `gowdk build --app` can generate an embedded Go app from that output, and `--bin` can compile it. `gowdk serve` can serve the generated SPA directory locally. It does not implement arbitrary client expressions yet. Only pages with `load {}` or `go ssr {}` should use request-time full-page rendering. +The current code can plan route metadata for CLI reports and can emit SPA HTML files, CSS assets from compile-time processors and discovered page CSS inputs, stylesheet links, page-aware processor stylesheet selections, `gowdk-routes.json`, `gowdk-assets.json`, `gowdk-build-report.json`, the partial-update client runtime, and generated island runtime assets when needed for simple build-time pages with explicit or discovered component and layout files. It expands the first literal `paths {}` subset for dynamic SPA routes, binds those route params plus literal `build {}` data or imported Go build data into the current SPA `view {}` interpolation context, resolves typed component props/state contracts from Go module imports, runs state init functions at build time, and composes SPA page layouts through each layout's single ``; literal `build {}` string values can also interpolate current route params. It parses the supported action body subset and can generate SPA POST redirect handlers plus form input decoders, required-field validation wrappers, typed same-package action decoder glue, user action/API calls, CSRF token wiring when `Build.CSRF.Enabled` is set, partial fragment responses, and concrete or dynamic standalone fragment routes through `addons/partial`; dynamic fragment params are attached to hook contexts through `runtime/app.Params(ctx)` and `runtime/app.TypedParams(ctx)`. `gowdk build --app` can also generate concrete and dynamic SSR routes for pages with request-time full-page behavior, with dynamic route matching, generated typed route-param bindings backed by `runtime/route`, and `load {}` execution for declared fields through `addons/ssr`; generated SSR load functions can return safe local redirects, generated SSR load failures can render optional `500.html`, and generated apps can render optional `404.html` for not-found responses. Generated guarded SSR, action, API, and fragment routes require `GOWDKGuardRegistry` for custom guard IDs and `GOWDKAuthProvider` for native `role:`/`permission:` RBAC guard IDs, fail Go compilation when required backing hooks are missing, and run declared guards through `runtime/guard` before user logic; ordinary guard errors fail closed while explicit guard helper errors can write no-store redirects or custom responses. Generated SSR, action, and API lanes also recover panics before response headers are written as no-store HTTP 500 responses without exposing panic values. `gowdk build --app` can generate an embedded Go app from that output, and `--bin` can compile it. `gowdk serve` can serve the generated SPA directory locally. It does not implement arbitrary client expressions yet. Only pages with `load {}` or `go ssr {}` should use request-time full-page rendering. Guard metadata declarations are parsed and exposed in manifest/site-map output. -`runtime/guard` defines guard context, registry, ordered execution, and native -RBAC resolution for generated action, API, fragment, and SSR routes. +`runtime/guard` defines guard context, registry, ordered execution, no-store +redirect/custom-response helpers, and native RBAC resolution for generated +action, API, fragment, and SSR routes. `runtime/auth` defines the thin native RBAC principal/provider contract used for generated route access gates. `addons/ssr` keeps guard type aliases for existing SSR-facing code, but generated action/API/fragment output does not import SSR diff --git a/docs/engineering/m7-ssr-hybrid-implementation-plan.md b/docs/engineering/m7-ssr-hybrid-implementation-plan.md new file mode 100644 index 00000000..cf273850 --- /dev/null +++ b/docs/engineering/m7-ssr-hybrid-implementation-plan.md @@ -0,0 +1,42 @@ +# M7 SSR And Hybrid Implementation Plan + +This plan records the M7 closure scope for GitHub issues #7, #9, #10, #25, +#63, and #177. + +## Implementation + +- Retire the `fragment_dynamic_route` diagnostic and allow fragment endpoint + routes to use `{name}`, `{name:type}`, and final-segment `{name...}` params. +- Populate `appgen.FragmentEndpoint.RouteParams` from `internal/gwdkir` route + text. +- Generate standalone fragment handlers with exact static cases first and + ordered `runtime/route.Match` checks for dynamic routes. +- Attach raw params through `runtime/app.WithParams` and decoded typed params + through `runtime/app.WithTypedParams` before rate limits, guards, static + fallback output, or same-package fragment hooks run. +- Add `runtime/guard` redirect and custom response helpers and have generated + guard failures write those responses with the existing no-store policy. +- Let generated backend-only apps and split frontend proxy checks dispatch + dynamic fragment route patterns. +- Extend dynamic-route ambiguity validation from page/rest-only coverage to the + same-method generated request namespace, including fragments, APIs, actions, + Go endpoints, and contract references. +- Update partials, routing, deployment, hooks, product requirements, roadmap, + architecture, and release-plan docs with current M7 behavior and deferred + hybrid/guard/cache follow-ups. + +## Verification + +- `go test ./internal/source` +- `go test ./runtime/app` +- `go test ./internal/compiler` +- `go test ./internal/appgen` +- `go test ./...` +- `scripts/test-go-modules.sh` + +## Follow-Ups + +- Broader SSR and fragment examples remain tracked in the release plan. +- Richer request-local state beyond the current context helpers remains planned. +- Hybrid streaming, browser-owned data refresh, and non-HTTP revalidation remain + deferred until the base request-time lane is stable. diff --git a/docs/engineering/release-plan.md b/docs/engineering/release-plan.md index 65209d53..f8aa2253 100644 --- a/docs/engineering/release-plan.md +++ b/docs/engineering/release-plan.md @@ -470,31 +470,34 @@ Every 0.x minor release must have: ## SSR, Hybrid, Cache, Guards, And Auth Hooks -- [ ] Document SSR lifecycle, render mode, feature requirement, `load {}` +- [x] Document SSR lifecycle, render mode, feature requirement, `load {}` grammar, declared load paths, typed route params, `(T, error)` load functions, `context.Context` load functions, redirects, not found, custom errors, route-local error pages, endpoint-local error pages, panic boundaries, guard-before-load ordering, layout-data merge, and cache policy. - [ ] Add SSR examples for simple pages, dashboards, guarded account pages, - dynamic detail pages, and route-local error pages. -- [ ] Document hybrid lifecycle, bare hybrid behavior, hybrid with and without + dynamic detail pages, and route-local error pages. Deferred to + [#102](https://github.com/cssbruno/GoWDK/issues/102). +- [x] Document hybrid lifecycle, bare hybrid behavior, hybrid with and without `load`, SSR feature requirement, cache, revalidation, action invalidation, fragment refresh, and data refresh. -- [ ] Defer hybrid streaming until simpler behavior is stable. -- [ ] Add route/build report output that shows hybrid clearly. -- [ ] Document static asset, SPA HTML, SSR HTML, API, action, fragment, and +- [x] Defer hybrid streaming until simpler behavior is stable. +- [x] Add route/build report output that shows hybrid clearly. +- [x] Document static asset, SPA HTML, SSR HTML, API, action, fragment, and hybrid cache policy. -- [ ] Document `cache` and `revalidate`. -- [ ] Add route report cache column and build report cache section. -- [ ] Test immutable asset cache, SPA `no-cache`, request-time `no-store`, +- [x] Document `cache` and `revalidate`. +- [x] Add route report cache column and build report cache section. +- [x] Test immutable asset cache, SPA `no-cache`, request-time `no-store`, `cache`, `revalidate`, and invalid `revalidate`. -- [ ] Document guard syntax, required backing hooks, guard failure behavior, and +- [x] Document guard syntax, required backing hooks, guard failure behavior, and support matrix for SSR, actions, APIs, fragments, and hybrid. -- [ ] Document request context helpers for request, params, CSRF, session, and +- [x] Document request context helpers for request, params, CSRF, session, and app context. - [ ] Add user-owned session, cookie session, bearer token, admin role, guest-only page, JSON auth failure, redirect auth failure, and partial auth - failure examples. + failure examples. Deferred to + [#102](https://github.com/cssbruno/GoWDK/issues/102) and + [#337](https://github.com/cssbruno/GoWDK/issues/337). ## Components, Client Language, SPA Navigation, And WASM diff --git a/docs/language/guards.md b/docs/language/guards.md index df2ff6d2..6ca59f76 100644 --- a/docs/language/guards.md +++ b/docs/language/guards.md @@ -64,8 +64,12 @@ Do not rely on static hosting alone to protect a guardless page. ## Status `guard` validation currently records and checks metadata and enforces the -default-deny described above. Full authorization and broader request-time policy -are still planned — see [docs/engineering/security.md](../engineering/security.md). +default-deny described above. Guard functions return `nil` to allow a request +or an `error` to stop it. Ordinary errors fail closed with 403; explicit +`runtime/guard.RedirectTo`, `runtime/guard.Redirect`, and +`runtime/guard.Respond` errors write no-store redirects or custom responses. +Full authorization and richer request-local state are still planned — see +[docs/engineering/security.md](../engineering/security.md). ## Related diff --git a/docs/language/hybrid.md b/docs/language/hybrid.md index 05f0841a..7842cfd6 100644 --- a/docs/language/hybrid.md +++ b/docs/language/hybrid.md @@ -1,10 +1,28 @@ # Hybrid Rendering -Hybrid rendering is not exposed as source syntax. +Hybrid rendering is not exposed as separate source syntax. Pages default to build-time SPA output. Use `load {}` or `go ssr {}` when a page must run through generated request-time rendering. Both require the SSR addon. -The compiler still has internal route metadata for future hybrid behavior, but -there is no page metadata declaration for selecting it in `.gwdk` files. +The compiler still has internal `hybrid` route metadata for generated route +reports and configured render defaults, but there is no page metadata +declaration for selecting hybrid behavior in `.gwdk` files. A page without +`load {}` remains build-time SPA output; a page with `load {}` or `go ssr {}` +uses the integrated request-time page lane. + +Current generated hybrid behavior is deliberately narrow: + +- Concrete and dynamic request-time pages can be built into generated binaries. +- Page-level `cache` and `revalidate` use the same HTTP Cache-Control contract + as SPA and SSR HTML. +- Actions and fragments refresh data explicitly through redirects, fragment + responses, JSON, or reload responses. + +Deferred hybrid behavior: + +- streaming responses; +- browser-owned server-data refresh; +- non-HTTP revalidation; +- implicit action invalidation of page load data. diff --git a/docs/language/partials.md b/docs/language/partials.md index ea387b7e..c84205a3 100644 --- a/docs/language/partials.md +++ b/docs/language/partials.md @@ -2,7 +2,7 @@ Partial updates use server fragments, not full-page SSR. The generated slice supports action-driven fragment responses for SPA/action pages and standalone -static fragment routes. +concrete or dynamic fragment routes. Current support: @@ -29,18 +29,28 @@ Current support: fragment Patients GET "/patients/list" "#patients" {
Patients
} + + fragment PatientVitals GET "/patients/{id:int}/vitals" "#patients" { +
Vitals
+ } ``` Generated apps register these as backend endpoints, not page route kinds. - They currently require `GET`, a concrete absolute path without route params, - and a literal id-selector target. + They currently require `GET`, an absolute route pattern, and a literal + id-selector target. Fragment route params use the same syntax as page routes: + `{name}`, `{name:type}`, and final-segment `{name...}`. Supported scalar + types are `string`, `int`, `int64`, `uint`, `uint64`, `bool`, and `float64`. - If the same package exports a function with the fragment name and signature `func(context.Context) (response.Response, error)`, generated apps call that user-owned hook at request time. The hook owns data loading, validation, redirects, HTML, JSON, and fragment response decisions through `runtime/response.Response`. `runtime/app.Request(ctx)` exposes the current - request. If no function with the fragment name exists, the generated handler - serves the static rendered fragment body. + request, `runtime/app.Params(ctx)` exposes raw dynamic route params, and + `runtime/app.TypedParams(ctx)` exposes decoded typed route params. Generated + typed fragment bindings return `400` for invalid scalar params and `404` for + missing params before guards or fragment hooks run. If no function with the + fragment name exists, the generated handler serves the static rendered + fragment body. - Generated embedded app action handlers can respond to `X-GOWDK-Partial` requests with rendered fragment HTML, `Cache-Control: no-store`, and fragment target metadata. Normal POST requests still use the redirect/no-content diff --git a/docs/language/ssr.md b/docs/language/ssr.md index 713aceef..5d796f11 100644 --- a/docs/language/ssr.md +++ b/docs/language/ssr.md @@ -12,7 +12,10 @@ SSR is optional and must not become the default framework identity. literal or imported `build {}` data, and declared `load {}` data. - Dynamic SSR routes such as `/blog/{slug}` can be matched by generated binaries in the first supported slice. Route params render through generated - placeholders and request-time HTML escaping. + placeholders and request-time HTML escaping. Generated handlers attach raw + params through `runtime/app.Params(ctx)` and decoded typed params through + `runtime/app.TypedParams(ctx)` before guards, load functions, or rendering + run. Invalid typed params return 400; missing params return 404. - Generated SSR supports declared identifier and dotted-path fields such as `load { => { user, title, account.plan } }` and calls a same-package exported Go function named `Load`. `` is the explicit `page` value @@ -42,6 +45,12 @@ SSR is optional and must not become the default framework identity. - Non-redirect `load {}` failures also use the same 5xx message policy: ordinary error details are hidden, and only explicit `response.HandlerError.Message` values are rendered to clients. +- Page layouts compose around SSR pages at request time. Declared load data is + merged into the request render scope before the page and layout stack are + written. +- Successful SSR HTML uses the page `cache`/`revalidate` policy when declared + and otherwise uses `Cache-Control: no-store`. Load redirects, guard failures, + route-local error pages, and panic boundaries are always no-store. - The SSR addon exposes a small router registration contract for generated SSR page handlers. - The SSR addon provides a default HTTP 500 error handler contract for @@ -57,7 +66,10 @@ SSR is optional and must not become the default framework identity. `Context`, `Registry`, and ordered guard execution contracts. Generated SSR, action, API, and fragment handlers run declared guards before user logic. A guarded generated app will not compile unless required guard backing - functions exist. Native RBAC guard IDs use `role:` and + functions exist. Ordinary guard errors fail closed with HTTP 403. Guards can + intentionally return `runtime/guard.RedirectTo`, `runtime/guard.Redirect`, or + `runtime/guard.Respond` errors to write no-store redirects or custom + responses. Native RBAC guard IDs use `role:` and `permission:` and resolve through an application-owned `runtime/auth.Provider`. diff --git a/docs/product/m7-ssr-hybrid-spec.md b/docs/product/m7-ssr-hybrid-spec.md new file mode 100644 index 00000000..a1d53413 --- /dev/null +++ b/docs/product/m7-ssr-hybrid-spec.md @@ -0,0 +1,49 @@ +# M7 SSR And Hybrid Spec + +M7 hardens the current request-time page lane. GOWDK still defaults pages to +build-time SPA output; request-time page behavior is selected explicitly with +`load {}` or `go ssr {}` and requires the SSR addon. + +## In Scope + +- Concrete and dynamic request-time SSR pages in generated binaries. +- `load { => { field, nested.path } }` execution through same-package Go load + functions. +- Raw and typed route params through `runtime/app.Params(ctx)` and + `runtime/app.TypedParams(ctx)`. +- Standalone concrete and dynamic fragment routes, including typed fragment + route params for request-time fragment hooks. +- Route-local SSR `error` pages, optional generated `404.html`/`500.html`, and + no-store panic boundaries for generated request-time lanes. +- Guard enforcement for generated SSR, action, API, and fragment routes through + `runtime/guard` and optional native RBAC through `runtime/auth`, including + no-store guard redirect and response helpers. +- Current HTTP cache policy: generated assets use asset-manifest policy, SPA + HTML defaults to `no-cache`, request-time endpoint responses default to + `no-store`, and page `cache` / `revalidate` compile into Cache-Control for + successful SPA and SSR HTML. +- Hybrid source contract decision: no separate `.gwdk` hybrid syntax yet. + Hybrid remains internal/configured route metadata; source authors choose + request-time page behavior with `load {}` or `go ssr {}`. + +## Out Of Scope + +- Hybrid streaming. +- Browser-owned server-data refresh. +- Non-HTTP revalidation. +- Implicit action invalidation of page load data. +- Richer request-local state beyond the current `context.Context`, + `runtime/app`, and `runtime/guard.Context` helpers. +- Generated per-route param struct types. + +## Acceptance + +- Dynamic fragment routes compile, route, and pass raw and typed params to + same-package fragment hooks. +- Dynamic fragment paths participate in same-method route conflict validation + with pages, fragments, APIs, actions, Go endpoints, and contract routes. +- Current SSR, hybrid, cache, guard, and request context contracts are + documented in the product, language, routing, deployment, hooks, and + architecture docs. +- Guards can return ordinary errors for fail-closed 403 responses or explicit + guard helper errors for safe local redirects and custom no-store responses. diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 4acfa532..1ebc2635 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -36,9 +36,9 @@ language references, compiler docs, and examples. | PRD-008 | Keep runtime render core reusable across build-time pages, backend fragments, and request-time pages. | High | Implemented | `runtime/render` exists independently from `addons/ssr`; SSR is integrated through compiler/runtime hooks and enabled by feature registration. | | PRD-009 | Generate build-output/prerender output for v0.1. | High | Partial | `gowdk build --out` emits app-shell HTML, `gowdk-routes.json`, `gowdk-assets.json`, and `gowdk-build-report.json` for simple build-time pages, the first literal dynamic path subset, literal build data, imported and same-package no-argument Go build data functions returning `T` or `(T, error)`, scalar build fields, earlier-field references, string concatenation, numeric arithmetic, boolean logic, comparisons, and explicit or discovered components. Generated app handlers exist for the supported action/API/fragment/SSR slices; arbitrary build-time statements beyond expression records, route-param arguments to imported build functions, and full component semantics remain planned. | | PRD-010 | Provide CSS processor addon extension points without adding Tailwind to the compiler core or runtime core. | High | Partial | `FeatureCSS`, `addons/css`, configured stylesheet links, compile-time CSS processors, discovered CSS inputs, extracted literal classes, `css` page selection, generated page CSS output, CSS asset manifest entries, page-aware processor stylesheet selections, component CSS AST/IR scope and hash metadata, emitted scoped component CSS, emitted component `asset` files, scoped selector/keyframe rewriting, AST-only config loading for built-in addons, executable config loading for external importable addons, an experimental Tailwind v4 standalone-CLI wrapper, and generated CSS/component asset content-hashed emitted filenames are implemented; richer CSS processor addon capabilities remain planned. | -| PRD-011 | Support embedded assets and one-binary serving. | High | Partial | `addons/embed` and `runtime/asset` boundaries exist; `gowdk serve` can serve generated build output locally; `gowdk build --app` can generate an embedded app, `--bin` can compile it into one binary, and `--wasm` can compile a Go `js/wasm` artifact for SPA pages, feature-bound action/API handlers, action redirects, action fragments, standalone fragments, concrete or dynamic SSR pages with declared `load {}` identifier or dotted paths, and concrete or dynamic hybrid request-time pages with or without declared `load {}` data. | -| PRD-012 | Support server fragments for partial updates without full-page SSR. | Medium | Partial | `addons/partial`, generated client runtime emission, generated action fragment responses for partial POSTs, standalone fragment routes, generated required-field validation fragments for partial POSTs, generated CSRF validation when enabled, and first-slice generated JavaScript islands for local component state are implemented. Richer fragment rendering and broader local client-side reactivity remain planned. | -| PRD-013 | Complete request-time page rendering with `load {}`, guards, layouts, and error handling. | Medium | Partial | `addons/ssr` registers the SSR feature and provides load context aliases, route registration, request-aware layout composition, safe local redirect errors, default error-handler contracts, and declared load path resolution. `runtime/guard` provides shared guard context/registry/execution for generated SSR/action/API/fragment routes, and `runtime/auth` provides thin native RBAC principal/provider helpers for defense-in-depth generated route access gates; backend authorization remains normal Go code and is never replaced by guard metadata. Generated embedded apps can serve concrete and dynamic request-time SSR pages rendered from `view {}` and literal or imported `build {}` data, generated SSR/action/API/fragment routes require `GOWDKGuardRegistry` for custom guard IDs and `GOWDKAuthProvider` for native `role:`/`permission:` guard IDs, fail Go compilation when required backing hooks are missing, run declared guards before user logic, and have generated-binary coverage for registered guard success paths, `load { => { field, user.name } }` execution calls same-package Go load functions through `ssr.LoadContext`, optional generated `404.html`/`500.html` pages are used by runtime app error responses, SSR routes can declare `error "/errors/page.html"` for route-local generated load/render failure and route panic pages, action/API declarations can declare endpoint-local `error` pages for generated panic boundaries, and generated SSR/action/API lanes have no-store panic boundaries. | +| PRD-011 | Support embedded assets and one-binary serving. | High | Partial | `addons/embed` and `runtime/asset` boundaries exist; `gowdk serve` can serve generated build output locally; `gowdk build --app` can generate an embedded app, `--bin` can compile it into one binary, and `--wasm` can compile a Go `js/wasm` artifact for SPA pages, feature-bound action/API handlers, action redirects, action fragments, standalone concrete or dynamic fragments, concrete or dynamic SSR pages with declared `load {}` identifier or dotted paths, and concrete or dynamic hybrid request-time pages with or without declared `load {}` data. | +| PRD-012 | Support server fragments for partial updates without full-page SSR. | Medium | Partial | `addons/partial`, generated client runtime emission, generated action fragment responses for partial POSTs, standalone concrete and dynamic fragment routes with raw and typed route params for request-time hooks, generated required-field validation fragments for partial POSTs, generated CSRF validation when enabled, and first-slice generated JavaScript islands for local component state are implemented. Richer fragment rendering and broader local client-side reactivity remain planned. | +| PRD-013 | Complete request-time page rendering with `load {}`, guards, layouts, and error handling. | Medium | Partial | `addons/ssr` registers the SSR feature and provides load context aliases, route registration, request-aware layout composition, safe local redirect errors, default error-handler contracts, and declared load path resolution. `runtime/guard` provides shared guard context/registry/execution plus no-store redirect/custom-response helpers for generated SSR/action/API/fragment routes, and `runtime/auth` provides thin native RBAC principal/provider helpers for defense-in-depth generated route access gates; backend authorization remains normal Go code and is never replaced by guard metadata. Generated embedded apps can serve concrete and dynamic request-time SSR pages rendered from `view {}` and literal or imported `build {}` data, generated SSR/action/API/fragment routes require `GOWDKGuardRegistry` for custom guard IDs and `GOWDKAuthProvider` for native `role:`/`permission:` guard IDs, fail Go compilation when required backing hooks are missing, run declared guards before user logic, and have generated-binary coverage for registered guard success and redirect paths, `load { => { field, user.name } }` execution calls same-package Go load functions through `ssr.LoadContext`, optional generated `404.html`/`500.html` pages are used by runtime app error responses, SSR routes can declare `error "/errors/page.html"` for route-local generated load/render failure and route panic pages, action/API declarations can declare endpoint-local `error` pages for generated panic boundaries, and generated SSR/action/API lanes have no-store panic boundaries. | | PRD-014 | Add optional WASM islands after the core compiler and action flow are stable. | Low | Partial | Component-level `wasm` declarations make normal calls to that component emit WASM and loader assets under `assets/gowdk/islands/`; explicit `g:island="wasm"` remains supported as a call-site override. Declared `wasm` browser-side Go packages and page-level `go client {}` mounts are compiled with `GOOS=js GOARCH=wasm`, checked for browser-unsafe imports, ship the Go `wasm_exec.js` runtime asset, instantiate through Go runtime imports when needed, and validate required GOWDK ABI exports. Browser-runtime integration coverage exercises the generated host loader mount, event, patch, emit, and cleanup contract; fuller user-code runtime validation remains planned. | | PRD-015 | Provide language tools for `.gwdk` token inspection, formatting, validation, manifest output, and LSP editor integration. | High | Implemented | `internal/lang`, `internal/lsp`, `internal/inspectreport`, and CLI commands exist, including source-linked inspect tree, endpoint graph output, and Go binding inspection. | | PRD-016 | Keep hybrid route behavior internal until a source contract is chosen. | High | Partial | Hybrid route metadata exists internally; `.gwdk` source selects request-time rendering with `load {}` or `go ssr {}`. | @@ -70,13 +70,13 @@ implemented. | Errors | Keep `error` for route-local SSR and action/API boundaries; define expected error types and layout boundaries later. | Partial | | Dev server | Keep dependency-free live reload as baseline; add browser error overlay before component-aware HMR. | Planned | | Routing | Add rest params and trailing-slash policy first while keeping explicit route declarations; defer optional params, route groups, and same-path page/API negotiation. | Partial — rest params `{name...}` are supported as the final segment of SSR page routes (string-only, one or more segments joined with `/`) with duplicate/ambiguity validation, and the trailing-slash policy is explicit (canonical declarations; GET/HEAD trailing-slash requests 308-redirect to the canonical path). Optional params, route groups, and same-path negotiation remain deferred with explicit diagnostics; see `docs/reference/routing.md`. | -| Typed generated APIs | Generate typed route-param accessors first; defer typed load/action data accessors until result contracts are stable. | Partial — generated request-time handlers attach raw route params through `app.Params(ctx)` and decoded typed params through `app.TypedParams(ctx)`; per-route param structs and typed load/action result accessors remain planned. | +| Typed generated APIs | Generate typed route-param accessors first; defer typed load/action data accessors until result contracts are stable. | Partial — generated SSR and fragment request-time handlers attach raw route params through `app.Params(ctx)` and decoded typed params through `app.TypedParams(ctx)`; per-route param structs and typed load/action result accessors remain planned. | | Inline Go authoring | Allow optional Go code blocks inside `.gwdk` only when they extract to normal importable, formatted, testable package Go. Separate `.go` files remain supported and generated adapters remain glue. Saved default `go {}` blocks are type-checked with sibling Go files during validation, default `go {}` blocks can provide build-time no-argument functions for `build { => LocalFunc() }` and same-page action/API/fragment handlers, page-level `go client {}` blocks can opt into client-side Go by exporting `GOWDKMount` for generated WASM page mounts, generated app source materializes default `go {}` and `go ssr {}` blocks under `gowdk_go/`, `go ssr {}` can provide generated SSR load handlers, and configured addons implementing `gowdk.GoBlockConsumer` can validate `go addon. {}` blocks and emit generated app Go files. Source-adjacent extraction and addon adapter contracts remain planned. | Partial | | Forms | Keep progressive-enhancement-first form behavior; full POST and enhanced POST share action result semantics; domain validation stays in user Go. | Partial | Generated enhanced forms preserve no-JavaScript POST behavior, send partial request headers, swap server fragments, expose failed enhanced response status/body/detail events, and use escaped live-region validation fragments. Domain validation stays in user Go. | | APIs | Broaden APIs through public request/response helpers and typed body/query helpers, not framework-specific adapters. | Partial — `addons/api` provides strict JSON body decoding, typed query helpers, and JSON/error/no-content response helpers for current `func(context.Context, *http.Request) (response.Response, error)` handlers. Generated typed handler signatures, per-route result contracts, CORS policy, and richer examples remain planned. | | Contract runtime | Add typed Go queries, commands, backend-owned domain/integration events, presentation events, and jobs after endpoint/adapter IR is stable. Frontend UI events trigger commands or queries, commands have one owner, domain events are emitted after backend state changes succeed, local in-process dispatch is default, and broker/outbox/worker roles are optional. First runtime registry, runtime role filtering helpers, event-envelope capture/replay with stable event IDs, stable observation names and labels for logs/metrics/traces, dependency-free outbox, broker, presentation-fanout, event-source, and seen-store interfaces, event worker loop with ack/nack, context cancellation, and optional post-ack deduplication windows, dependency-free file outbox adapter with retry metadata and opt-in dead-letter storage, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, Redis Streams broker/EventSource adapter, Redis TTL seen store, NATS broker/EventSource adapter, dependency-free SSE presentation fanout adapter, WebSocket presentation fanout adapter, generated command event sink registration, generated contract registry construction, generated worker replay helper with optional seen-store dedup, Go AST scanner, scan-local package inspection cache, local-package and imported-handler `go/types` diagnostics, local and imported contract/result type diagnostics, local exported struct/function contract diagnostics, duplicate command-owner scan diagnostics, generated-app import-cycle diagnostics, emitted-event category diagnostics, first browser-UI and vague event-name diagnostics, contract/list/graph/trace CLI, form-local `g:command` metadata with literal form method/action, element-local `g:query` metadata with page-route source metadata, import-path-aware command/query reference linking, `g:event` rejection, IR command/query references with exact source locations, command/query reference binding status, appgen adapter IR exposure metadata, command method/path adapter metadata, query page-route adapter metadata, generated web command/query adapters, page-route query JSON negotiation, stable JSON success/error response shape, AST/printer/format generated adapter source, page-guard propagation for generated command/query routes, rate-limit and guard preflight before contract execution, CSRF-before-command-decoding ordering, routes-report contract endpoint metadata, missing/invalid/non-web-role contract-reference diagnostics, enforced Go contract scan diagnostics in check/build, and build-report contract-reference events with role metadata are implemented; all diagnostic spans, fragment/API-specific query execution, split-binary worker/cron wiring, retry backoff policies, and managed deployment recipes remain planned. | Partial | -| Cache | Keep `cache` and `revalidate` as HTTP cache policy; keep action-driven data refresh explicit through redirects, fragments, JSON, or reload responses. | Partial | -| Guards | Extend guards with safe local redirects and response helpers before richer request-local state. | Planned | +| Cache | Keep `cache` and `revalidate` as HTTP cache policy; keep action-driven data refresh explicit through redirects, fragments, JSON, or reload responses. | Partial — route reports include route/endpoint cache metadata, build reports summarize generated cache policies, generated binaries apply immutable asset cache, SPA `no-cache`, request-time `no-store`, and page `cache`/`revalidate` for successful SPA/SSR HTML. | +| Guards | Extend guards with safe local redirects and response helpers before richer request-local state. | Partial — guards keep the `func(runtime/guard.Context) error` signature. Ordinary errors fail closed with 403, while `runtime/guard.RedirectTo`, `runtime/guard.Redirect`, and `runtime/guard.Respond` intentionally write no-store redirects or custom responses. Richer request-local state is still deferred. | | Component CSS | Make component CSS explicit, compiler-scoped, and documented; Tailwind and processors remain optional. | Partial | | Accessibility | Add accessibility diagnostics as compiler warnings with stable codes and spans. | Partial — `missing_img_alt` warns for literal `` elements without explicit `alt` in pages, components, and layouts; labels, empty links, button type, and heading order are deferred to #237. | | Diagnostics and LSP | Expand diagnostic catalogue before broad parser recovery; prioritize hover, semantic tokens, go-to-definition, and route/type navigation. | Partial — the diagnostic registry, `gowdk explain`, JSON check output, safe fix metadata, LSP diagnostics/formatting/completions/hover/definitions/references/code actions/semantic tokens, CLI route/sitemap/inspect reports, and [diagnostics/navigation contract](diagnostics-and-navigation.md) exist; parser recovery, broader exact spans, direct markup-family emitted codes, and workspace route/type navigation remain planned. | diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index a5c424ba..53e70b80 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -129,9 +129,10 @@ level, the current baseline already includes: - shared backend routing primitives in `runtime/app`, runtime action/API adapter helpers, one generated backend hook, request body limits, and no-store defaults for request-time responses; -- first-slice action/API execution, partial fragment responses, and concrete or - dynamic request-time SSR pages with declared `load {}` fields through - buildgen, appgen, `runtime/app`, and `runtime/route`. +- first-slice action/API execution, partial fragment responses, dynamic + standalone fragment routes, and concrete or dynamic request-time SSR pages + with declared `load {}` fields through buildgen, appgen, `runtime/app`, and + `runtime/route`. Do not roadmap those completed slices as future work. Future work should stabilize their contracts, remove generation debt, and fill the missing diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b45a8065..f3ee8657 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -184,9 +184,12 @@ paths still bypass discovery. A module with a name and no explicit include uses `testdata`, root/module `Source.Exclude` globs, and the configured build output directory when one exists. `build --out` overrides `Build.Output`; one of them is required for `build`. Every successful disk build writes -`gowdk-build-report.json` to the output root. Passing `--debug` prints the same -build report to stderr for validation, planning, write, manifest, cleanup, and -completion events without changing stdout artifact-path output. Passing +`gowdk-build-report.json` to the output root. The report includes validation, +planning, write, manifest, cache-policy, cleanup, and completion events; +request-time SSR/hybrid pages that are intentionally skipped from static +prerender output appear as `request_time_page_skipped` events. Passing `--debug` +prints the same build report to stderr without changing stdout artifact-path +output. Passing `--timings` writes `gowdk-build-timings.json` next to the build report, or `--timings=` writes the timing JSON to a custom path; timing data is kept out of `gowdk-build-report.json` so normal build reports stay deterministic. @@ -248,9 +251,10 @@ such as `static`, `spa`, `ssr`, and `hybrid`; route records include package, render/cache metadata, route params, layouts, guards, source file, source span, and planned handler. Backend actions, APIs, fragments, and routable command or query contracts appear in the separate `endpoints` list with source path, -source span, `.gwdk` package, method, path, page ID, no-store backend cache -policy, inherited guards, CSRF applicability, planned adapter handler, and -backend or contract binding metadata. Backend binding metadata includes the Go +source span, `.gwdk` package, method, path, page ID, route params when +declared, no-store backend cache policy, inherited guards, CSRF applicability, +planned adapter handler, and backend or contract binding metadata. Backend +binding metadata includes the Go package name, import path when known, handler symbol, signature/input metadata when bound, status, and binding message. Non-fatal route-mode notes, such as request-time page rendering disabled on a SPA route or static SPA output diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index edee26b4..4f8e7306 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -265,7 +265,8 @@ Generated binaries use explicit cache headers: errors, generated error pages, and invalid-CSRF responses use `Cache-Control: no-store`. - Page-level `cache` records route response cache intent in compiler, route, - manifest, generated asset metadata, and generated SSR route metadata. + build-report, manifest, generated asset metadata, and generated SSR route + metadata. Generated binaries apply it to successful static SPA HTML and SSR HTML responses for that page. It does not override the no-store safety policy for actions, APIs, partial responses, load redirects, generated errors, or @@ -425,6 +426,8 @@ Generated binaries currently support: the configured secret environment variable is present. - First-slice required-field validation for directly declared form controls. - First-slice partial action fragment responses. +- Standalone concrete and dynamic fragment routes with raw and typed route + params exposed to fragment hooks. - First-slice concrete and dynamic request-time SSR pages with declared `load {}` identifier or dotted paths. - Optional split frontend/backend generation with `--backend-app` and diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md index db9aea52..7d31e771 100644 --- a/docs/reference/diagnostic-codes.md +++ b/docs/reference/diagnostic-codes.md @@ -139,8 +139,7 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep `unknown_go_block_target`, `unknown_addon_go_block_target`, `unsupported_addon_go_block_target`, `addon_go_block_diagnostic`, `generated_app_import_cycle`. -- Partials and fragments: `unsupported_fragment_method`, - `fragment_dynamic_route`. +- Partials and fragments: `unsupported_fragment_method`. - Contracts: `contract_handler_invalid`, `contract_handler_missing`, `contract_type_invalid`, `contract_result_invalid`, `contract_input_invalid`, `contract_event_name_invalid`, diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md index 43c6be93..0131ec16 100644 --- a/docs/reference/hooks.md +++ b/docs/reference/hooks.md @@ -9,7 +9,7 @@ GOWDK's current hook model is small and `net/http`-first. | Generated app handler | `http.Handler` | Wrap with normal Go middleware in app startup. | | Guards | `runtime/guard.Registry`, `runtime/auth.Provider` | Generated action, API, fragment, and SSR routes with `guard`. | | Rate limiting | `*ratelimit.Limiter` | Generated action, API, fragment, SSR, and split-backend proxy routes when the addon is enabled. | -| Handler context | `context.Context` | User handlers read request metadata through `runtime/app` helpers. | +| Handler context | `context.Context` | User handlers read request metadata, raw route params, and typed route params through `runtime/app` helpers. | Generated apps expose `Handler() (http.Handler, error)` and `ServeMux() (*http.ServeMux, error)`. App-owned startup code can wrap the @@ -116,8 +116,37 @@ contract): - Guard errors fail closed with HTTP 403. - Guards run before action decoding, API handler calls, fragment hooks, SSR `load {}`, and user business logic. -- Guards return `nil` or `error` today. Redirect/custom response guard results - are planned. +- Guards return `nil` or `error`. Ordinary errors fail closed with HTTP 403. + `runtime/guard.RedirectTo`, `runtime/guard.Redirect`, and + `runtime/guard.Respond` are the explicit no-store redirect/custom-response + helpers for guard failures. + +Guard redirect and response helpers keep the guard signature small while making +the few intentional non-403 outcomes visible in code: + +```go +import ( + "net/http" + + gowdkguard "github.com/cssbruno/gowdk/runtime/guard" + gowdkresponse "github.com/cssbruno/gowdk/runtime/response" +) + +func GOWDKGuardRegistry() gowdkguard.Registry { + return gowdkguard.Registry{ + "auth.required": func(ctx gowdkguard.Context) error { + return gowdkguard.RedirectTo("/login") + }, + "api.auth": func(ctx gowdkguard.Context) error { + return gowdkguard.Respond(gowdkresponse.JSONBody(http.StatusUnauthorized, `{"error":"login required"}`)) + }, + } +} +``` + +Guard redirects must be local absolute paths. Protocol-relative URLs, +backslashes, newlines, and non-3xx redirect statuses are rejected before the +generated app can write them. ## Rate Limiting @@ -134,7 +163,8 @@ user handler logic. If no limiter is registered, requests continue. Current generated request-time order: -1. Attach route or endpoint context metadata. +1. Attach route or endpoint context metadata, including raw and typed route + params when the route declares them. 2. Install panic boundary for supported generated lanes. 3. Run rate limiter when enabled and registered. 4. Run guards when declared. diff --git a/docs/reference/routing.md b/docs/reference/routing.md index 83d4759a..3d43f3da 100644 --- a/docs/reference/routing.md +++ b/docs/reference/routing.md @@ -66,13 +66,13 @@ Rest param contract: Go through `app.Params(ctx)` and `route.Required(params, "name")`. - Rest params are always strings. Typed rest params such as `{path...:int}` are rejected. -- Rest params require request-time (SSR) rendering, because build-time SPA - paths cannot enumerate and escape multi-segment values. Declare `load {}` or - `go ssr {}` on the page. -- Rest params are only supported on page routes; action, API, fragment, and Go - comment endpoint paths reject them. An action or API that omits its path - inherits the page route, so inline endpoints on a rest page are rejected the - same way unless they declare their own concrete path. +- Rest params require request-time (SSR) rendering when used on page routes, + because build-time SPA paths cannot enumerate and escape multi-segment + values. Declare `load {}` or `go ssr {}` on the page. +- Rest params are supported on request-time fragment endpoint routes. Action, + API, and Go comment endpoint paths reject rest params. An action or API that + omits its path inherits the page route, so inline endpoints on a rest page + are rejected the same way unless they declare their own concrete path. - Rest routes participate in ambiguity validation: `/docs/{path...}` overlaps `/docs/{slug}`, `/docs/{section}/{slug}`, and concrete routes such as `/docs/guides/intro`, so those combinations are rejected as @@ -314,12 +314,13 @@ gowdk build --ssr --out /tmp/gowdk-ssr-build \ ``` Dynamic SSR route params render through generated placeholders and request-time -HTML escaping. Params can be declared as `{name}`, `{name:type}`, or — as the -final segment only — `{name...}` (always a string). Supported -types are `string`, `int`, `int64`, `uint`, `uint64`, `bool`, and `float64`. -Generated SSR handlers attach route metadata through `runtime/app.Route(ctx)`, -raw dynamic params through `runtime/app.Params(ctx)`, and decoded typed params -through `runtime/app.TypedParams(ctx)`. +HTML escaping. Dynamic fragment endpoint params are attached to fragment hook +contexts. Params can be declared as `{name}`, `{name:type}`, or — as the final +segment only — `{name...}` (always a string). Supported types are `string`, +`int`, `int64`, `uint`, `uint64`, `bool`, and `float64`. Generated SSR handlers +attach route metadata through `runtime/app.Route(ctx)`. Generated SSR and +fragment handlers attach raw dynamic params through `runtime/app.Params(ctx)` +and decoded typed params through `runtime/app.TypedParams(ctx)`. There are no generated per-route param struct types yet. Request-time user code should use `app.Params(ctx)`, `app.TypedParams(ctx)`, or the `runtime/route` @@ -343,8 +344,9 @@ _ = id The helpers support `String`, `Int`, `Int64`, `Uint`, `Uint64`, `Bool`, and `Float64`. `Required` returns a missing-param error when a required param is not present. Decode errors name the param and expected type without echoing the raw -request value. Generated typed SSR bindings return `400` for invalid typed route -params and `404` for missing route params before guards or page rendering run. +request value. Generated typed SSR and fragment bindings return `400` for +invalid typed route params and `404` for missing route params before guards, +page rendering, or fragment hooks run. Endpoint user code can read generated endpoint metadata with `runtime/app.Endpoint(ctx)`. This is the stable accessor for action, API, and @@ -379,12 +381,13 @@ fragment declaration, and routable `g:command`/`g:query` contract reference. Endpoint records include `endpointSource` (`gwdk`, `go`, or `contract`), source file and source span, `.gwdk` package, Go package path/name when known, exact declared symbol or contract reference, method, path, no-store backend cache -policy, inherited guards, CSRF applicability, planned adapter handler -information, and binding status/message. Backend binding details repeat the Go -package name, import path when known, handler symbol, and supported -signature/input metadata when the handler is bound. Contract binding details -include the contract kind, reference name, binding status, local input type, -result type, roles, handler, register function, and message when known. The +policy, inherited guards, CSRF applicability, route params when declared, +planned adapter handler information, and binding status/message. Backend +binding details repeat the Go package name, import path when known, handler +symbol, and supported signature/input metadata when the handler is bound. +Contract binding details include the contract kind, reference name, binding +status, local input type, result type, roles, handler, register function, and +message when known. The `info` list reports disabled route-mode lanes, for example SSR disabled on a SPA route. diff --git a/examples/partials/patients-fragment.page.gwdk b/examples/partials/patients-fragment.page.gwdk index 73f537e7..2b51a21d 100644 --- a/examples/partials/patients-fragment.page.gwdk +++ b/examples/partials/patients-fragment.page.gwdk @@ -6,6 +6,12 @@ guard public act Refresh POST "/patients" +fragment PatientVitals GET "/patients/{id:int}/vitals" "#patients" { +
+

Vitals fragment

+
+} + view {
diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index f3ceb06a..fd9a554c 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -1809,6 +1809,48 @@ func TestGenerateWritesTypedSSRRouteParamBindings(t *testing.T) { } } +func TestGenerateWritesDynamicFragmentRouteParamBindings(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + appDir := filepath.Join(root, "generated-app") + writeTestFile(t, filepath.Join(outputDir, "patients", "index.html"), "
Patients
") + + result, err := GenerateWithOptions(outputDir, appDir, Options{Fragments: []FragmentEndpoint{{ + PageID: "patients", + FragmentName: "Vitals", + Method: "GET", + Route: "/patients/{id:int}/vitals", + RouteParams: []source.RouteParam{{Name: "id", Type: "int"}}, + Target: "#vitals", + HTML: "
Vitals
", + }}}) + if err != nil { + t.Fatal(err) + } + payload, err := os.ReadFile(result.PackagePath) + if err != nil { + t.Fatal(err) + } + source := string(payload) + for _, expected := range []string{ + `gowdkroute "github.com/cssbruno/gowdk/runtime/route"`, + `if params, ok := gowdkroute.Match("/patients/{id:int}/vitals", request.URL.Path); request.Method == "GET" && ok {`, + `ctx = gowdkruntime.WithParams(ctx, params)`, + `paramValue0, paramOK0, paramErr0 := gowdkroute.Int(params, "id")`, + `gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid route parameter id")`, + `typedParams["id"] = paramValue0`, + `ctx = gowdkruntime.WithTypedParams(ctx, typedParams)`, + `gowdkpartial.Fragment("#vitals", "
Vitals
")`, + } { + if !strings.Contains(source, expected) { + t.Fatalf("expected generated main.go to contain %q:\n%s", expected, source) + } + } + if strings.Contains(source, `requestPath == "/patients/{id:int}/vitals"`) { + t.Fatalf("expected generated main.go not to use exact literal match for dynamic fragment route:\n%s", source) + } +} + func TestGenerateAutoDetectsActionAndSSRRoutes(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") @@ -1975,6 +2017,7 @@ func TestGenerateWritesGuardRegistryAndGuardChecks(t *testing.T) { `func init()`, `RegisterGuards(GOWDKGuardRegistry())`, `gowdkguard.RunGuardsWithAuth(guardContext, guards, guardRegistry, authProvider)`, + `gowdkguard.WriteNoStoreFailure(response, err)`, `if !runGuards(response, request, []string{"auth.required"})`, } { if !strings.Contains(source, expected) { @@ -4378,6 +4421,60 @@ func Session(context.Context, *http.Request) (gowdkresponse.Response, error) { } } +func TestGeneratedBinaryGuardCanRedirectRequestTimeRoute(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + appDir := filepath.Join(root, "generated-app") + binaryPath := filepath.Join(root, "site") + writeTestFile(t, filepath.Join(outputDir, "index.html"), "
Home
") + + if _, err := GenerateWithOptions(outputDir, appDir, Options{ + SSR: []SSRRoute{{ + PageID: "dashboard", + Route: "/dashboard", + Guards: []string{"auth.required"}, + HTML: "

Request Dashboard

", + }}, + }); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(appDir, appPackageDirName, "guards_register.go"), `package gowdkapp + +import gowdkguard "github.com/cssbruno/gowdk/runtime/guard" + +func GOWDKGuardRegistry() gowdkguard.Registry { + return gowdkguard.Registry{ + "auth.required": func(ctx gowdkguard.Context) error { + return gowdkguard.RedirectTo("/login") + }, + } +} +`) + if _, err := BuildBinary(appDir, binaryPath); err != nil { + t.Fatal(err) + } + + addr := freeAddr(t) + command := exec.Command(binaryPath) + command.Env = append(os.Environ(), "GOWDK_ADDR="+addr) + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _, _ = command.Process.Wait() + }() + + response, err := waitForHTTPStatus("http://"+addr+"/dashboard", http.MethodGet, "") + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusSeeOther || response.Header.Get("Location") != "/login" || response.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("expected no-store guard redirect, got status=%d headers=%v", response.StatusCode, response.Header) + } +} + func TestGeneratedBinaryNativeRBACGuardUsesRegisteredAuthProvider(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") @@ -5170,6 +5267,100 @@ func List(ctx context.Context) (response.Response, error) { } } +func TestGeneratedBinaryExecutesDynamicFragmentUserHook(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + appDir := filepath.Join(root, "generated-app") + binaryPath := filepath.Join(root, "site") + writeTestFile(t, filepath.Join(outputDir, "patients", "index.html"), "
Patients
") + + if _, err := GenerateWithOptions(outputDir, appDir, Options{Fragments: []FragmentEndpoint{{ + PageID: "patients", + FragmentName: "Vitals", + Method: "GET", + Route: "/patients/{id:int}/vitals", + Target: "#vitals", + HTML: "

Static fallback

", + Binding: source.BackendBinding{ + Status: source.BackendBindingBound, + ImportPath: "gowdk-generated-app/patients", + PackageName: "patients", + FunctionName: "Vitals", + Signature: source.BackendSignatureFragment, + }, + }}}); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(appDir, "patients", "patients.go"), `package patients + +import ( + "context" + "fmt" + + gowdkapp "github.com/cssbruno/gowdk/runtime/app" + "github.com/cssbruno/gowdk/runtime/response" +) + +func Vitals(ctx context.Context) (response.Response, error) { + params := gowdkapp.TypedParams(ctx) + id, ok := params["id"].(int) + if !ok { + return response.HTMLBody(500, "missing typed id"), nil + } + return response.FragmentFor("#vitals", fmt.Sprintf("

Patient %d

", id)), nil +} +`) + if _, err := BuildBinary(appDir, binaryPath); err != nil { + t.Fatal(err) + } + + addr := freeAddr(t) + command := exec.Command(binaryPath) + command.Env = append(os.Environ(), "GOWDK_ADDR="+addr) + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _, _ = command.Process.Wait() + }() + + response, err := waitForHTTPStatus("http://"+addr+"/patients/42/vitals", http.MethodGet, "") + if err != nil { + t.Fatal(err) + } + payload, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("expected fragment response status 200, got %d: %s", response.StatusCode, payload) + } + if strings.TrimSpace(string(payload)) != "

Patient 42

" { + t.Fatalf("expected runtime fragment hook response, got %s", payload) + } + if response.Header.Get("X-GOWDK-Fragment-Target") != "#vitals" { + t.Fatalf("unexpected fragment target: %q", response.Header.Get("X-GOWDK-Fragment-Target")) + } + + invalid, err := waitForHTTPStatus("http://"+addr+"/patients/not-int/vitals", http.MethodGet, "") + if err != nil { + t.Fatal(err) + } + invalidPayload, err := io.ReadAll(invalid.Body) + _ = invalid.Body.Close() + if err != nil { + t.Fatal(err) + } + if invalid.StatusCode != http.StatusBadRequest { + t.Fatalf("expected invalid typed route param status 400, got %d: %s", invalid.StatusCode, invalidPayload) + } + if !strings.Contains(string(invalidPayload), "invalid route parameter id") { + t.Fatalf("expected invalid route parameter body, got %s", invalidPayload) + } +} + func hiddenInputValue(markup string, name string) string { marker := `name="` + name + `" value="` start := strings.Index(markup, marker) diff --git a/internal/appgen/ir.go b/internal/appgen/ir.go index 023c0767..fe8a7f0e 100644 --- a/internal/appgen/ir.go +++ b/internal/appgen/ir.go @@ -134,6 +134,7 @@ func fragmentEndpointsFromIR(ir gwdkir.Program) ([]FragmentEndpoint, error) { FragmentName: fragment.Name, Method: method, Route: strings.TrimSpace(fragment.Route), + RouteParams: gwdkir.RouteParamsFromPath(strings.TrimSpace(fragment.Route)), Target: fragment.Target, HTML: html, Package: page.Package, diff --git a/internal/appgen/ir_test.go b/internal/appgen/ir_test.go index 3adfb84f..220a32c0 100644 --- a/internal/appgen/ir_test.go +++ b/internal/appgen/ir_test.go @@ -114,6 +114,9 @@ func TestFragmentEndpointsFromIR(t *testing.T) { if endpoint.FragmentName != "List" || endpoint.Route != "/patients/list" || endpoint.Target != "#patients" { t.Fatalf("unexpected fragment endpoint: %#v", endpoint) } + if len(endpoint.RouteParams) != 0 { + t.Fatalf("did not expect static fragment route params, got %#v", endpoint.RouteParams) + } if endpoint.HTML != "
Updated & safe
" { t.Fatalf("unexpected rendered fragment HTML: %q", endpoint.HTML) } @@ -122,6 +125,41 @@ func TestFragmentEndpointsFromIR(t *testing.T) { } } +func TestFragmentEndpointsFromIRPopulatesRouteParams(t *testing.T) { + endpoints, err := fragmentEndpointsFromIR(gwdkir.Program{ + Version: gwdkir.Version, + Pages: []gwdkir.Page{{ + ID: "patients", + Route: "/patients", + Blocks: gwdkir.Blocks{ + Fragments: []gwdkir.FragmentEndpoint{{ + Name: "Vitals", + Method: "GET", + Route: "/patients/{id:int}/vitals/{section...}", + Target: "#vitals", + Body: `
Vitals
`, + }}, + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(endpoints) != 1 { + t.Fatalf("expected one fragment endpoint, got %#v", endpoints) + } + want := []source.RouteParam{{Name: "id", Type: "int"}, {Name: "section", Type: "string"}} + got := endpoints[0].RouteParams + if len(got) != len(want) { + t.Fatalf("RouteParams = %#v, want %#v", got, want) + } + for index := range want { + if got[index].Name != want[index].Name || got[index].Type != want[index].Type { + t.Fatalf("RouteParams = %#v, want %#v", got, want) + } + } +} + func TestStandaloneGoEndpointsFromIR(t *testing.T) { ir := gwdkir.Program{ Version: gwdkir.Version, diff --git a/internal/appgen/source.go b/internal/appgen/source.go index 95f7dc61..b039bdf6 100644 --- a/internal/appgen/source.go +++ b/internal/appgen/source.go @@ -57,8 +57,13 @@ func runtimeImportMap(options Options) map[string]string { ssr := options.SSR if len(actions) > 0 || len(fragments) > 0 { imports["gowdkresponse"] = "github.com/cssbruno/gowdk/runtime/response" + } + if len(actions) > 0 || fragmentsUseExactRoutes(fragments) { imports["path"] = "path" } + if fragmentsUseDynamicRoutes(fragments) { + imports["gowdkroute"] = "github.com/cssbruno/gowdk/runtime/route" + } if actionsUseFragments(actions) || fragmentsUseStaticFallback(fragments) { imports["gowdkpartial"] = "github.com/cssbruno/gowdk/addons/partial" } @@ -90,6 +95,9 @@ func runtimeImportMap(options Options) map[string]string { } if options.ProxyBackend { imports["gowdkresponse"] = "github.com/cssbruno/gowdk/runtime/response" + if fragmentsUseDynamicRoutes(options.Fragments) { + imports["gowdkroute"] = "github.com/cssbruno/gowdk/runtime/route" + } imports["neturl"] = "net/url" imports["os"] = "os" imports["httputil"] = "net/http/httputil" diff --git a/internal/appgen/source_backend.go b/internal/appgen/source_backend.go index 72f39c6c..f239deaa 100644 --- a/internal/appgen/source_backend.go +++ b/internal/appgen/source_backend.go @@ -152,6 +152,9 @@ func isBackendRouteDecl(options Options) *ast.FuncDecl { }) } for _, fragment := range sortedFragmentEndpoints(options.Fragments) { + if fragmentRouteIsDynamic(fragment) { + continue + } clauses = append(clauses, &ast.CaseClause{ List: []ast.Expr{backendRouteCond(stringLit(fragment.Method), fragment.Route)}, Body: []ast.Stmt{returnBool(true)}, @@ -163,14 +166,25 @@ func isBackendRouteDecl(options Options) *ast.FuncDecl { Body: []ast.Stmt{returnBool(true)}, }) } - clauses = append(clauses, &ast.CaseClause{Body: []ast.Stmt{returnBool(false)}}) + body := []ast.Stmt{} + if fragmentsUseDynamicRoutes(options.Fragments) { + body = append(body, define([]ast.Expr{id("rawRequestPath")}, id("requestPath"))) + } + body = append(body, + assign([]ast.Expr{id("requestPath")}, call(sel("path", "Clean"), &ast.BinaryExpr{X: stringLit("/"), Op: token.ADD, Y: id("requestPath")})), + &ast.SwitchStmt{Body: &ast.BlockStmt{List: clauses}}, + ) + for _, fragment := range sortedFragmentEndpoints(options.Fragments) { + if !fragmentRouteIsDynamic(fragment) { + continue + } + body = append(body, backendDynamicRouteIfStmt(stringLit(fragment.Method), fragment.Route)) + } + body = append(body, returnBool(false)) return funcDecl("isBackendRoute", []*ast.Field{ {Names: []*ast.Ident{id("method")}, Type: id("string")}, {Names: []*ast.Ident{id("requestPath")}, Type: id("string")}, - }, boolResults(), []ast.Stmt{ - assign([]ast.Expr{id("requestPath")}, call(sel("path", "Clean"), &ast.BinaryExpr{X: stringLit("/"), Op: token.ADD, Y: id("requestPath")})), - &ast.SwitchStmt{Body: &ast.BlockStmt{List: clauses}}, - }) + }, boolResults(), body) } func backendRouteCond(method ast.Expr, route string) ast.Expr { @@ -188,3 +202,19 @@ func backendRouteCond(method ast.Expr, route string) ast.Expr { }, } } + +func backendDynamicRouteIfStmt(method ast.Expr, route string) ast.Stmt { + return &ast.IfStmt{ + Init: define([]ast.Expr{id("_"), id("ok")}, call(sel("gowdkroute", "Match"), stringLit(route), id("rawRequestPath"))), + Cond: &ast.BinaryExpr{ + X: &ast.BinaryExpr{ + X: id("method"), + Op: token.EQL, + Y: method, + }, + Op: token.LAND, + Y: id("ok"), + }, + Body: block(returnBool(true)), + } +} diff --git a/internal/appgen/source_backend_app.go b/internal/appgen/source_backend_app.go index cad46e89..af9a08ee 100644 --- a/internal/appgen/source_backend_app.go +++ b/internal/appgen/source_backend_app.go @@ -46,9 +46,12 @@ func backendRuntimeImportMap(options Options) map[string]string { if len(options.APIs) > 0 { imports["path"] = "path" } - if len(options.Fragments) > 0 { + if fragmentsUseExactRoutes(options.Fragments) { imports["path"] = "path" } + if fragmentsUseDynamicRoutes(options.Fragments) { + imports["gowdkroute"] = "github.com/cssbruno/gowdk/runtime/route" + } if actionsUseFragments(options.Actions) || fragmentsUseStaticFallback(options.Fragments) { imports["gowdkpartial"] = "github.com/cssbruno/gowdk/addons/partial" } diff --git a/internal/appgen/source_fragments.go b/internal/appgen/source_fragments.go index 843e6628..a2db7107 100644 --- a/internal/appgen/source_fragments.go +++ b/internal/appgen/source_fragments.go @@ -5,6 +5,7 @@ import ( "go/token" "sort" + "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/source" ) @@ -12,22 +13,36 @@ func fragmentFuncDecl(fragments []FragmentEndpoint, rateLimit bool) *ast.FuncDec if len(fragments) == 0 { return funcDecl("fragment", actionParams(), boolResults(), []ast.Stmt{returnBool(false)}) } + sorted := sortedFragmentEndpoints(fragments) + body := []ast.Stmt{} var clauses []ast.Stmt - for _, fragment := range sortedFragmentEndpoints(fragments) { + for _, fragment := range sorted { + if fragmentRouteIsDynamic(fragment) { + continue + } clauses = append(clauses, &ast.CaseClause{ List: []ast.Expr{fragmentCaseExpr(fragment)}, Body: fragmentCaseStmts(fragment, rateLimit), }) } - clauses = append(clauses, &ast.CaseClause{Body: []ast.Stmt{returnBool(false)}}) - return funcDecl("fragment", actionParams(), boolResults(), []ast.Stmt{ - define([]ast.Expr{id("requestPath")}, call(sel("path", "Clean"), &ast.BinaryExpr{ - X: stringLit("/"), - Op: token.ADD, - Y: selExpr(selExpr(id("request"), "URL"), "Path"), - })), - &ast.SwitchStmt{Body: &ast.BlockStmt{List: clauses}}, - }) + if len(clauses) > 0 { + body = append(body, + define([]ast.Expr{id("requestPath")}, call(sel("path", "Clean"), &ast.BinaryExpr{ + X: stringLit("/"), + Op: token.ADD, + Y: selExpr(selExpr(id("request"), "URL"), "Path"), + })), + &ast.SwitchStmt{Body: &ast.BlockStmt{List: clauses}}, + ) + } + for _, fragment := range sorted { + if !fragmentRouteIsDynamic(fragment) { + continue + } + body = append(body, fragmentDynamicIfStmt(fragment, rateLimit)) + } + body = append(body, returnBool(false)) + return funcDecl("fragment", actionParams(), boolResults(), body) } func fragmentCaseExpr(fragment FragmentEndpoint) ast.Expr { @@ -47,7 +62,7 @@ func fragmentCaseExpr(fragment FragmentEndpoint) ast.Expr { } func fragmentCaseStmts(fragment FragmentEndpoint, rateLimit bool) []ast.Stmt { - stmts := endpointContextStmts("fragment", fragment.PageID, fragment.FragmentName, fragment.Method, fragment.Route, "") + stmts := fragmentContextStmts(fragment, false) stmts = append(stmts, rateLimitStmts(rateLimit)...) stmts = append(stmts, guardStmts(fragment.Guards)...) if fragment.Binding.Status == source.BackendBindingUnsupportedSignature { @@ -78,6 +93,73 @@ func fragmentCaseStmts(fragment FragmentEndpoint, rateLimit bool) []ast.Stmt { return stmts } +func fragmentDynamicIfStmt(fragment FragmentEndpoint, rateLimit bool) ast.Stmt { + return &ast.IfStmt{ + Init: define([]ast.Expr{id("params"), id("ok")}, call(sel("gowdkroute", "Match"), stringLit(fragment.Route), selExpr(selExpr(id("request"), "URL"), "Path"))), + Cond: &ast.BinaryExpr{ + X: &ast.BinaryExpr{ + X: selExpr(id("request"), "Method"), + Op: token.EQL, + Y: stringLit(fragment.Method), + }, + Op: token.LAND, + Y: id("ok"), + }, + Body: block(fragmentDynamicCaseStmts(fragment, rateLimit)...), + } +} + +func fragmentDynamicCaseStmts(fragment FragmentEndpoint, rateLimit bool) []ast.Stmt { + stmts := fragmentContextStmts(fragment, true) + stmts = append(stmts, rateLimitStmts(rateLimit)...) + stmts = append(stmts, guardStmts(fragment.Guards)...) + if fragment.Binding.Status == source.BackendBindingUnsupportedSignature { + stmts = append(stmts, backendNotImplementedStmts(fragment.Binding, "fragment")...) + stmts = append(stmts, returnBool(true)) + return stmts + } + if fragment.Binding.Status == source.BackendBindingBound { + stmts = append(stmts, + define([]ast.Expr{id("result"), id("err")}, call(sel(fragment.BackendAlias, fragment.Binding.FunctionName), id("ctx"))), + &ast.IfStmt{ + Cond: notNil("err"), + Body: block( + writeNoStoreHandlerErrorExprStmt(id("err"), sel("http", "StatusInternalServerError")), + returnBool(true), + ), + }, + writeNoStoreHTTPStmt(id("result")), + returnBool(true), + ) + return stmts + } + stmts = append(stmts, + define([]ast.Expr{id("fragment")}, call(sel("gowdkpartial", "Fragment"), stringLit(fragment.Target), stringLit(fragment.HTML))), + writeNoStoreHTTPStmt(id("fragment")), + returnBool(true), + ) + return stmts +} + +func fragmentContextStmts(fragment FragmentEndpoint, includeParams bool) []ast.Stmt { + stmts := []ast.Stmt{ + endpointContextStmt("fragment", fragment.PageID, fragment.FragmentName, fragment.Method, fragment.Route, ""), + } + if includeParams { + stmts = append(stmts, assign([]ast.Expr{id("ctx")}, call(sel("gowdkruntime", "WithParams"), id("ctx"), id("params")))) + stmts = append(stmts, typedRouteParamStmts(fragmentTypedRouteParams(fragment))...) + } + stmts = append(stmts, assign([]ast.Expr{id("request")}, call(selExpr(id("request"), "WithContext"), id("ctx")))) + return stmts +} + +func fragmentTypedRouteParams(fragment FragmentEndpoint) []source.RouteParam { + if len(fragment.RouteParams) > 0 { + return fragment.RouteParams + } + return gwdkir.RouteParamsFromPath(fragment.Route) +} + func fragmentsUseStaticFallback(fragments []FragmentEndpoint) bool { for _, fragment := range fragments { if fragment.Binding.Status != source.BackendBindingBound && fragment.Binding.Status != source.BackendBindingUnsupportedSignature { @@ -87,6 +169,28 @@ func fragmentsUseStaticFallback(fragments []FragmentEndpoint) bool { return false } +func fragmentsUseExactRoutes(fragments []FragmentEndpoint) bool { + for _, fragment := range fragments { + if !fragmentRouteIsDynamic(fragment) { + return true + } + } + return false +} + +func fragmentsUseDynamicRoutes(fragments []FragmentEndpoint) bool { + for _, fragment := range fragments { + if fragmentRouteIsDynamic(fragment) { + return true + } + } + return false +} + +func fragmentRouteIsDynamic(fragment FragmentEndpoint) bool { + return len(ssrRoutePatternParams(fragment.Route)) > 0 +} + func sortedFragmentEndpoints(fragments []FragmentEndpoint) []FragmentEndpoint { sorted := append([]FragmentEndpoint(nil), fragments...) sort.Slice(sorted, func(i, j int) bool { diff --git a/internal/appgen/source_guards.go b/internal/appgen/source_guards.go index 938404c2..ee12d673 100644 --- a/internal/appgen/source_guards.go +++ b/internal/appgen/source_guards.go @@ -126,7 +126,7 @@ func runGuardsDecl() ast.Decl { Init: define([]ast.Expr{id("err")}, call(sel("gowdkguard", "RunGuardsWithAuth"), id("guardContext"), id("guards"), id("guardRegistry"), id("authProvider"))), Cond: notNil("err"), Body: block( - writeNoStoreErrorExprStmt(sel("http", "StatusForbidden"), call(selExpr(id("err"), "Error"))), + exprStmt(call(sel("gowdkguard", "WriteNoStoreFailure"), id("response"), id("err"))), returnBool(false), ), }, diff --git a/internal/appgen/types.go b/internal/appgen/types.go index 15da0319..f8cf5422 100644 --- a/internal/appgen/types.go +++ b/internal/appgen/types.go @@ -79,6 +79,7 @@ type FragmentEndpoint struct { FragmentName string Method string Route string + RouteParams []source.RouteParam Target string HTML string Package string diff --git a/internal/appgen/validate_actions.go b/internal/appgen/validate_actions.go index a3bddf96..c2fb4f40 100644 --- a/internal/appgen/validate_actions.go +++ b/internal/appgen/validate_actions.go @@ -97,7 +97,7 @@ func validateFragmentEndpoints(endpoints []FragmentEndpoint) error { if endpoint.Method != "GET" { return fmt.Errorf("generated fragment %s.%s uses unsupported method %s; fragments currently require GET", endpoint.PageID, endpoint.FragmentName, endpoint.Method) } - if err := validateActionEndpointPath(endpoint.Route); err != nil { + if err := source.ValidateBackendRoutePattern(endpoint.Route); err != nil { return fmt.Errorf("generated fragment %s.%s: %w", endpoint.PageID, endpoint.FragmentName, err) } if err := validateFragmentTargetValue(endpoint.Target); err != nil { diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 83d6a7be..a28859e0 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -1,9 +1,11 @@ package buildgen import ( + "encoding/json" "errors" "fmt" "path/filepath" + "sort" "strings" "github.com/cssbruno/gowdk" @@ -125,6 +127,7 @@ func buildFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings []sourc } result.AssetManifestPath = assetManifestPath reporter.info("manifest", "asset_manifest_written", "asset manifest written", BuildEvent{Path: eventPath(outputDir, assetManifestPath)}) + reportCachePolicies(reporter, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) openAPIPath, err := writeOpenAPI(outputDir, ir) if err != nil { return Result{}, reporter.fail("report", err) @@ -263,6 +266,7 @@ func buildMemoryFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings [ } result.Files[assetManifestFile] = assetManifest reporter.info("manifest", "asset_manifest_collected", "asset manifest collected", BuildEvent{Path: assetManifestFile}) + reportCachePolicies(reporter, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) openAPI, err := openAPIPayload(ir) if err != nil { return MemoryResult{}, reporter.fail("report", err) @@ -555,6 +559,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st } result.AssetManifestPath = assetManifestPath reporter.info("manifest", "asset_manifest_written", "asset manifest written", BuildEvent{Path: eventPath(outputDir, assetManifestPath)}) + reportCachePolicies(reporter, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) openAPIPath, err := writeOpenAPI(outputDir, ir) if err != nil { return Result{}, reporter.fail("report", err) @@ -600,6 +605,86 @@ func reportSkippedPrerenderPages(reporter *buildReporter, config gowdk.Config, i } } +func reportCachePolicies(reporter *buildReporter, pages []Artifact, css []CSSArtifact, assets []AssetArtifact) { + data := map[string]string{ + "pageHtml": fmt.Sprint(len(pages)), + "css": fmt.Sprint(len(css)), + "assets": fmt.Sprint(len(assets)), + "defaultPageHTML": noCacheAssetCachePolicy, + "defaultRequestTime": "no-store", + } + if policies := cachePolicyCounts(pageCachePolicies(pages)); policies != "" { + data["pageHTMLPolicies"] = policies + } + if policies := cachePolicyCounts(cssCachePolicies(css)); policies != "" { + data["cssPolicies"] = policies + } + if policies := cachePolicyCounts(assetCachePolicies(assets)); policies != "" { + data["assetPolicies"] = policies + } + reporter.info("report", "cache_policy", "cache policies summarized", BuildEvent{Data: data}) +} + +func pageCachePolicies(artifacts []Artifact) []string { + policies := make([]string, 0, len(artifacts)) + for _, artifact := range artifacts { + policy := artifact.CachePolicy + if policy == "" { + policy = noCacheAssetCachePolicy + } + policies = append(policies, policy) + } + return policies +} + +func cssCachePolicies(artifacts []CSSArtifact) []string { + policies := make([]string, 0, len(artifacts)) + for _, artifact := range artifacts { + policy := artifact.CachePolicy + if policy == "" { + policy = immutableAssetCachePolicy + } + policies = append(policies, policy) + } + return policies +} + +func assetCachePolicies(artifacts []AssetArtifact) []string { + policies := make([]string, 0, len(artifacts)) + for _, artifact := range artifacts { + policy := artifact.CachePolicy + if policy == "" { + policy = noCacheAssetCachePolicy + } + policies = append(policies, policy) + } + return policies +} + +func cachePolicyCounts(policies []string) string { + if len(policies) == 0 { + return "" + } + counts := map[string]int{} + for _, policy := range policies { + counts[policy]++ + } + keys := make([]string, 0, len(counts)) + for policy := range counts { + keys = append(keys, policy) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, policy := range keys { + key, err := json.Marshal(policy) + if err != nil { + continue + } + parts = append(parts, fmt.Sprintf("%s:%d", key, counts[policy])) + } + return "{" + strings.Join(parts, ",") + "}" +} + func planFromIR(config gowdk.Config, ir gwdkir.Program, outputDir string) (buildPlan, error) { components, componentFailures := buildComponents(ir.Components) layouts, layoutFailures := buildLayouts(ir.Layouts) diff --git a/internal/buildgen/build_report_test.go b/internal/buildgen/build_report_test.go index 7be4bdfd..0715fb87 100644 --- a/internal/buildgen/build_report_test.go +++ b/internal/buildgen/build_report_test.go @@ -418,6 +418,51 @@ func TestBuildReportIncludesBoundContractReferenceRoles(t *testing.T) { } } +func TestBuildReportIncludesCachePolicySummary(t *testing.T) { + outputDir := t.TempDir() + app := gwdkanalysis.Sources{Pages: []gwdkir.Page{ + { + ID: "home", + Route: "/", + Cache: "public, max-age=120", + Revalidate: "30", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
About
`, + }, + }, + { + ID: "about", + Route: "/about", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
About
`, + }, + }, + }} + + result, err := Build(gowdk.Config{}, app, outputDir) + if err != nil { + t.Fatal(err) + } + event := findBuildReportEvent(result.Report, "report", "cache_policy") + if event == nil { + t.Fatalf("missing cache_policy event in %#v", result.Report.Events) + } + for key, expected := range map[string]string{ + "pageHtml": "2", + "assets": "1", + "defaultPageHTML": "no-cache", + "defaultRequestTime": "no-store", + "pageHTMLPolicies": `{"no-cache":1,"public, max-age=120, stale-while-revalidate=30":1}`, + "assetPolicies": `{"no-cache":1}`, + } { + if event.Data[key] != expected { + t.Fatalf("expected cache policy data %s=%q, got %#v", key, expected, event.Data) + } + } +} + func TestBuildReportIncludesBackendBindingEndpointMetadata(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") diff --git a/internal/compiler/route_bindings.go b/internal/compiler/route_bindings.go index bdc74210..f163a2e8 100644 --- a/internal/compiler/route_bindings.go +++ b/internal/compiler/route_bindings.go @@ -72,6 +72,8 @@ type EndpointBinding struct { Method string Route string Cache string + DynamicParams []string + RouteParams []source.RouteParam Guards []string CSRF bool PageID string @@ -200,6 +202,8 @@ func BuildRouteMetadataFromIR(config gowdk.Config, ir gwdkir.Program) RouteMetad Method: endpoint.Method, Route: endpoint.Path, Cache: endpoint.Cache, + DynamicParams: append([]string(nil), endpoint.DynamicParams...), + RouteParams: append([]source.RouteParam(nil), endpoint.RouteParams...), Guards: append([]string(nil), endpoint.Guards...), CSRF: endpoint.CSRF, PageID: endpoint.PageID, @@ -229,6 +233,8 @@ func BuildRouteMetadataFromIR(config gowdk.Config, ir gwdkir.Program) RouteMetad Method: endpoint.Method, Route: endpoint.Path, Cache: endpoint.Cache, + DynamicParams: append([]string(nil), endpoint.DynamicParams...), + RouteParams: append([]source.RouteParam(nil), endpoint.RouteParams...), Guards: append([]string(nil), endpoint.Guards...), CSRF: endpoint.CSRF, PageID: endpoint.PageID, @@ -254,6 +260,8 @@ func BuildRouteMetadataFromIR(config gowdk.Config, ir gwdkir.Program) RouteMetad Method: endpoint.Method, Route: endpoint.Path, Cache: endpoint.Cache, + DynamicParams: append([]string(nil), endpoint.DynamicParams...), + RouteParams: append([]source.RouteParam(nil), endpoint.RouteParams...), Guards: append([]string(nil), endpoint.Guards...), CSRF: endpoint.CSRF, PageID: endpoint.PageID, diff --git a/internal/compiler/route_bindings_test.go b/internal/compiler/route_bindings_test.go index 0b20c1b5..c833abba 100644 --- a/internal/compiler/route_bindings_test.go +++ b/internal/compiler/route_bindings_test.go @@ -37,7 +37,7 @@ func TestBuildRouteMetadataSeparatesRoutesFromEndpoints(t *testing.T) { Blocks: gwdkir.Blocks{ View: true, APIs: []gwdkir.API{{Name: "List", Method: "GET", Route: "/api/patients"}}, - Fragments: []gwdkir.FragmentEndpoint{{Name: "Table", Method: "GET", Route: "/patients/table", Target: "#patients", Body: "
Patients
"}}, + Fragments: []gwdkir.FragmentEndpoint{{Name: "Table", Method: "GET", Route: "/patients/{id:int}/table", Target: "#patients", Body: "
Patients
"}}, }, }, }, @@ -52,7 +52,13 @@ func TestBuildRouteMetadataSeparatesRoutesFromEndpoints(t *testing.T) { assertRoute(t, metadata.Routes, RouteSSR, "GET", "/dashboard", "ssr.RenderDashboard") assertEndpoint(t, metadata.Endpoints, EndpointAction, "POST", "/newsletter", "actions.NewsletterSubscribe") assertEndpoint(t, metadata.Endpoints, EndpointAPI, "GET", "/api/patients", "api.PatientsIndexList") - assertEndpoint(t, metadata.Endpoints, EndpointFragment, "GET", "/patients/table", "fragments.PatientsIndexTable") + fragment := findEndpoint(t, metadata.Endpoints, EndpointFragment, "GET", "/patients/{id:int}/table", "fragments.PatientsIndexTable") + if len(fragment.DynamicParams) != 1 || fragment.DynamicParams[0] != "id" { + t.Fatalf("expected fragment dynamic param id, got %#v", fragment.DynamicParams) + } + if len(fragment.RouteParams) != 1 || fragment.RouteParams[0].Name != "id" || fragment.RouteParams[0].Type != "int" { + t.Fatalf("expected typed fragment route param id:int, got %#v", fragment.RouteParams) + } assertInfo(t, metadata.Info, "ssr_disabled", "newsletter") assertInfo(t, metadata.Info, "spa_disabled", "dashboard") } diff --git a/internal/compiler/routes.go b/internal/compiler/routes.go index 7a961864..1408775d 100644 --- a/internal/compiler/routes.go +++ b/internal/compiler/routes.go @@ -65,18 +65,17 @@ func duplicateRouteMessage(route, firstID, firstSource, duplicateID, duplicateSo return message } -func validateAmbiguousDynamicPageRoutes(pages []gwdkir.Page, endpoints []gwdkir.GoEndpoint) []ValidationError { +func validateAmbiguousDynamicPageRoutes(pages []gwdkir.Page, endpoints []gwdkir.GoEndpoint, refs []gwdkir.ContractReference) []ValidationError { var registered []routeRegistration var diagnostics []ValidationError - for _, current := range routeRegistrations(pages, endpoints, nil) { + for _, current := range routeRegistrations(pages, endpoints, refs) { for _, previous := range registered { if current.Pattern == previous.Pattern { // Exact duplicates are reported by the duplicate-route and // route-method-conflict checks. continue } - bothPages := current.Kind == "page" && previous.Kind == "page" - if bothPages { + if current.Kind == "page" && previous.Kind == "page" { // Dynamic routes are compared against other dynamic routes. // Rest routes match one or more trailing segments, so they // are also compared against concrete routes that share their @@ -87,12 +86,13 @@ func validateAmbiguousDynamicPageRoutes(pages []gwdkir.Page, endpoints []gwdkir. continue } } else { - // Endpoints cannot declare rest routes themselves, but a - // same-method endpoint inside a rest page's namespace would - // shadow part of it at request time, so flag that overlap. - restPage := (current.Kind == "page" && patternHasRest(current.Pattern)) || - (previous.Kind == "page" && patternHasRest(previous.Pattern)) - if !restPage || current.Method != previous.Method { + // Same-method endpoints share one generated request-time + // namespace with pages. Any dynamic overlap can shadow the + // concrete handler that should own a request path. + if current.Method != previous.Method { + continue + } + if !patternIsDynamic(current.Pattern) && !patternIsDynamic(previous.Pattern) { continue } } @@ -113,7 +113,7 @@ func validateAmbiguousDynamicPageRoutes(pages []gwdkir.Page, endpoints []gwdkir. } func ambiguousDynamicRouteMessage(current, previous routeRegistration) string { - message := fmt.Sprintf("ambiguous dynamic page route %q overlaps %q", current.Route, previous.Route) + message := fmt.Sprintf("ambiguous dynamic route %q overlaps %q", current.Route, previous.Route) if current.Owner != "" && previous.Owner != "" { message = fmt.Sprintf("%s; %s could match the same request path as %s", message, current.Owner, previous.Owner) } diff --git a/internal/compiler/validate.go b/internal/compiler/validate.go index 4613e752..9b4778a1 100644 --- a/internal/compiler/validate.go +++ b/internal/compiler/validate.go @@ -97,7 +97,7 @@ func validateProgram(config gowdk.Config, ir gwdkir.Program, crossFile bool) Val diagnostics = append(diagnostics, validatePageLayoutReferences(ir.Pages, ir.Layouts)...) diagnostics = append(diagnostics, validateGoBlocks(config, ir)...) diagnostics = append(diagnostics, validateUniquePageRoutes(ir.Pages)...) - diagnostics = append(diagnostics, validateAmbiguousDynamicPageRoutes(ir.Pages, ir.GoEndpoints)...) + diagnostics = append(diagnostics, validateAmbiguousDynamicPageRoutes(ir.Pages, ir.GoEndpoints, ir.ContractRefs)...) diagnostics = append(diagnostics, validateRouteMethodConflicts(ir.Pages, ir.GoEndpoints, ir.ContractRefs)...) diagnostics = append(diagnostics, validateStandaloneEndpoints(ir.GoEndpoints)...) diagnostics = append(diagnostics, validateContractReferenceRoutes(ir.ContractRefs)...) diff --git a/internal/compiler/validate_page.go b/internal/compiler/validate_page.go index 47e306ad..d1a113b6 100644 --- a/internal/compiler/validate_page.go +++ b/internal/compiler/validate_page.go @@ -124,22 +124,8 @@ func ValidatePage(config gowdk.Config, page gwdkir.Page) []ValidationError { Message: fmt.Sprintf("%s fragment %s uses unsupported method %s; fragments currently require GET", page.ID, fragment.Name, method), }) } - fragmentRoute, issues := parseRoute(fragment.Route) + _, issues := parseRoute(fragment.Route) diagnostics = append(diagnostics, routeDiagnostics(page, fmt.Sprintf("fragment %s endpoint path", fragment.Name), issues, fragment.RouteSpan, fragment.RouteParams)...) - if len(issues) == 0 && len(fragmentRoute.Params) > 0 { - diagnostics = append(diagnostics, ValidationError{ - Code: "fragment_dynamic_route", - PageID: page.ID, - Source: page.Source, - Span: firstNamedSpan(fragment.RouteParams, fragment.RouteSpan), - Message: fmt.Sprintf( - "%s fragment %s endpoint path %q must be concrete; dynamic fragment routes are not supported yet", - page.ID, - fragment.Name, - fragment.Route, - ), - }) - } } if requiresSSRFeature(mode, page) && !config.HasFeature(gowdk.FeatureSSR) { diff --git a/internal/compiler/validate_test.go b/internal/compiler/validate_test.go index 5641ca61..28606b12 100644 --- a/internal/compiler/validate_test.go +++ b/internal/compiler/validate_test.go @@ -3777,6 +3777,117 @@ func TestValidateManifestAllowsConcreteRouteBesideDynamicPageRoute(t *testing.T) } } +func TestValidateManifestRejectsDynamicFragmentEndpointOverlap(t *testing.T) { + tests := []struct { + name string + fragment gwdkir.FragmentEndpoint + api gwdkir.API + expectMsg []string + }{ + { + name: "dynamic fragment shadows concrete fragment", + fragment: gwdkir.FragmentEndpoint{Name: "Summary", Method: "GET", Route: "/patients/summary/vitals", Target: "#vitals"}, + expectMsg: []string{ + "/patients/summary/vitals", + "fragment patients.Summary", + "fragment patients.Vitals", + }, + }, + { + name: "concrete api shadows dynamic fragment", + api: gwdkir.API{Name: "Summary", Method: "GET", Route: "/patients/summary/vitals"}, + expectMsg: []string{"/patients/summary/vitals", "api patients.Summary", "fragment patients.Vitals"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + page := gwdkir.Page{ + ID: "patients", + Route: "/patients", + Blocks: gwdkir.Blocks{View: true}, + } + page.Blocks.Fragments = []gwdkir.FragmentEndpoint{{ + Name: "Vitals", + Method: "GET", + Route: "/patients/{id:int}/vitals", + Target: "#vitals", + }} + if test.fragment.Name != "" { + page.Blocks.Fragments = append(page.Blocks.Fragments, test.fragment) + } + if test.api.Name != "" { + page.Blocks.APIs = append(page.Blocks.APIs, test.api) + } + + err := validateManifest(gowdk.Config{}, appFixture{Pages: []gwdkir.Page{page}}) + if err == nil { + t.Fatal("expected ambiguous dynamic route diagnostic") + } + diagnostics := err.(ValidationErrors) + if !hasDiagnosticMessage(diagnostics, "ambiguous_dynamic_route", test.expectMsg...) { + t.Fatalf("Missing ambiguous_dynamic_route diagnostic: %#v", diagnostics) + } + }) + } +} + +func TestValidateManifestRejectsDynamicFragmentContractOverlap(t *testing.T) { + program := appFixture{Pages: []gwdkir.Page{{ + ID: "patients", + Route: "/patients", + Blocks: gwdkir.Blocks{View: true, Fragments: []gwdkir.FragmentEndpoint{{ + Name: "Vitals", + Method: "GET", + Route: "/patients/{id:int}/vitals", + Target: "#vitals", + }}}, + }}}.program(gowdk.Config{}) + program.ContractRefs = append(program.ContractRefs, gwdkir.ContractReference{ + Kind: gwdkir.ContractQuery, + Name: "patients.GetVitals", + Method: "GET", + Path: "/patients/42/vitals", + OwnerKind: gwdkir.SourcePage, + OwnerID: "patients", + Source: "pages/patients.page.gwdk", + }) + + err := ValidateProgram(gowdk.Config{}, program) + if err == nil { + t.Fatal("expected ambiguous dynamic route diagnostic") + } + diagnostics := err.(ValidationErrors) + if !hasDiagnosticMessage(diagnostics, "ambiguous_dynamic_route", "/patients/42/vitals", "query contract patients.GetVitals", "fragment patients.Vitals") { + t.Fatalf("Missing ambiguous_dynamic_route diagnostic: %#v", diagnostics) + } +} + +func TestValidateManifestAllowsDifferentMethodEndpointBesideDynamicFragment(t *testing.T) { + app := appFixture{Pages: []gwdkir.Page{{ + ID: "patients", + Route: "/patients", + Blocks: gwdkir.Blocks{ + View: true, + Fragments: []gwdkir.FragmentEndpoint{{ + Name: "Vitals", + Method: "GET", + Route: "/patients/{id:int}/vitals", + Target: "#vitals", + }}, + Actions: []gwdkir.Action{{ + Name: "SaveVitals", + Method: "POST", + Route: "/patients/summary/vitals", + }}, + }, + }}} + + if err := validateManifest(gowdk.Config{}, app); err != nil { + t.Fatalf("expected different methods to be valid, got %v", err) + } +} + func TestValidateManifestRejectsRouteMethodConflicts(t *testing.T) { t.Run("multiple actions on one route", func(t *testing.T) { app := appFixture{ @@ -4111,26 +4222,46 @@ func TestValidatePageRouteDiagnosticsUseExactSpans(t *testing.T) { assertSourceSpan(t, diagnostic.Span, 9, 21, 9, 30) }) - t.Run("fragment dynamic route", func(t *testing.T) { + t.Run("fragment malformed param type", func(t *testing.T) { page := page page.Blocks.Fragments = []gwdkir.FragmentEndpoint{{ Name: "Preview", Method: "GET", - Route: "/preview/{id}", + Route: "/preview/{id:uuid}", Span: testSourceSpan(12, 1, 12, 34), - RouteSpan: testSourceSpan(12, 20, 12, 35), - RouteParams: []source.NamedSpan{{Name: "id", Span: testSourceSpan(12, 29, 12, 33)}}, + RouteSpan: testSourceSpan(12, 20, 12, 40), + RouteParams: []source.NamedSpan{{Name: "id", Span: testSourceSpan(12, 29, 12, 38)}}, }} diagnostics := ValidatePage(gowdk.Config{}, irPage(page)) - diagnostic := firstDiagnostic(diagnostics, "fragment_dynamic_route") + diagnostic := firstDiagnostic(diagnostics, "malformed_route") if diagnostic == nil { - t.Fatalf("Missing fragment_dynamic_route diagnostic: %#v", diagnostics) + t.Fatalf("Missing malformed_route diagnostic: %#v", diagnostics) } - assertSourceSpan(t, diagnostic.Span, 12, 29, 12, 33) + assertSourceSpan(t, diagnostic.Span, 12, 29, 12, 38) }) } +func TestValidatePageAllowsDynamicFragmentRouteParams(t *testing.T) { + page := gwdkir.Page{ + ID: "patients", + Route: "/patients", + Blocks: gwdkir.Blocks{View: true}, + } + page.Blocks.Fragments = []gwdkir.FragmentEndpoint{{ + Name: "Vitals", + Method: "GET", + Route: "/patients/{id:int}/vitals", + Target: "#vitals", + RouteParams: []source.NamedSpan{{Name: "id", Span: testSourceSpan(6, 20, 6, 28)}}, + }} + + diagnostics := ValidatePage(gowdk.Config{}, irPage(page)) + if hasDiagnosticCode(diagnostics, "malformed_route") { + t.Fatalf("dynamic fragment route should be valid, got %#v", diagnostics) + } +} + func TestValidatePageRejectsRevalidateWithoutCache(t *testing.T) { page := gwdkir.Page{ID: "home", Route: "/", Revalidate: "60", Blocks: gwdkir.Blocks{View: true}} diff --git a/internal/diagnostics/registry.go b/internal/diagnostics/registry.go index ed551822..d385971b 100644 --- a/internal/diagnostics/registry.go +++ b/internal/diagnostics/registry.go @@ -59,7 +59,7 @@ var missingUseFix = &Fix{ var Registry = []Code{ {Code: "addon_go_block_diagnostic", Area: "go-block", Stability: StabilityAddon, Severity: SeverityError, Summary: "addon-provided go block diagnostic without a custom code"}, {Code: "ambiguous_backend_handler", Area: "backend", Stability: StabilityStable, Severity: SeverityWarning, Summary: "a backend handler is declared in both same-package Go and an inline go block"}, - {Code: "ambiguous_dynamic_route", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "dynamic page route overlaps another dynamic page route"}, + {Code: "ambiguous_dynamic_route", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "dynamic route pattern overlaps another route pattern"}, {Code: "backend_binding_required", Area: "backend", Stability: StabilityStable, Severity: SeverityError, Summary: "strict builds require a supported backend handler binding"}, {Code: "client_go_block_wasm_build_error", Area: "wasm", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page go client WASM build failed"}, {Code: "client_go_block_wasm_entrypoint_error", Area: "wasm", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page go client WASM entrypoint is missing or invalid"}, @@ -97,7 +97,6 @@ var Registry = []Code{ {Code: "duplicate_route", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "page route pattern is duplicated"}, {Code: "duplicate_route_param", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "route declares the same param more than once"}, {Code: "env_name_required", Area: "config", Stability: StabilityStable, Severity: SeverityError, Summary: "environment variable contract entry is missing a name"}, - {Code: "fragment_dynamic_route", Area: "partials", Stability: StabilityExperimental, Severity: SeverityError, Summary: "fragment endpoint path is dynamic, which is not supported yet"}, {Code: "generated_app_import_cycle", Area: "generated-go", Stability: StabilityStable, Severity: SeverityError, Summary: "generated app would import itself through user code"}, {Code: "go_client_requires_page", Area: "go-block", Stability: StabilityExperimental, Severity: SeverityError, Summary: "go client block was declared outside a page"}, {Code: "go_endpoint_parse_error", Area: "backend", Stability: StabilityStable, Severity: SeverityError, Summary: "Go endpoint comment scan failed to parse a Go file"}, diff --git a/internal/gwdkanalysis/ir_builder.go b/internal/gwdkanalysis/ir_builder.go index d844fa67..117a98dc 100644 --- a/internal/gwdkanalysis/ir_builder.go +++ b/internal/gwdkanalysis/ir_builder.go @@ -176,6 +176,7 @@ func (builder *irBuilder) addPageEndpoints(page gwdkir.Page) { CSRF: builder.config.Build.CSRF.Enabled, ErrorPage: action.ErrorPage, DynamicParams: routeParams(path), + RouteParams: copyRouteParams(gwdkir.RouteParamsFromPath(path)), SourceFile: page.Source, Span: action.Span, }) @@ -194,6 +195,7 @@ func (builder *irBuilder) addPageEndpoints(page gwdkir.Page) { Guards: append([]string(nil), page.Guards...), ErrorPage: api.ErrorPage, DynamicParams: routeParams(path), + RouteParams: copyRouteParams(gwdkir.RouteParamsFromPath(path)), SourceFile: page.Source, Span: api.Span, }) @@ -210,6 +212,7 @@ func (builder *irBuilder) addPageEndpoints(page gwdkir.Page) { Cache: endpointNoStoreCache, Guards: append([]string(nil), page.Guards...), DynamicParams: routeParams(fragment.Route), + RouteParams: copyRouteParams(gwdkir.RouteParamsFromPath(fragment.Route)), SourceFile: page.Source, Span: fragment.Span, }) @@ -351,6 +354,7 @@ func (builder *irBuilder) addStandaloneEndpoint(endpoint gwdkir.GoEndpoint) { Cache: endpointNoStoreCache, CSRF: builder.config.Build.CSRF.Enabled && kind == gwdkir.EndpointAction, DynamicParams: routeParams(endpoint.Route), + RouteParams: copyRouteParams(gwdkir.RouteParamsFromPath(endpoint.Route)), SourceFile: endpoint.Source, Span: endpoint.Span, }) diff --git a/internal/gwdkir/ir.go b/internal/gwdkir/ir.go index ef37113a..a58ed1e0 100644 --- a/internal/gwdkir/ir.go +++ b/internal/gwdkir/ir.go @@ -354,6 +354,7 @@ type Endpoint struct { CSRF bool ErrorPage string DynamicParams []string + RouteParams []source.RouteParam SourceFile string Span source.SourceSpan Binding Binding diff --git a/internal/lang/sitemap.go b/internal/lang/sitemap.go index 064bd615..04b89787 100644 --- a/internal/lang/sitemap.go +++ b/internal/lang/sitemap.go @@ -55,6 +55,8 @@ type SiteMapEndpoint struct { PageID string `json:"pageId"` Symbol string `json:"symbol,omitempty"` Package string `json:"package,omitempty"` + DynamicParams []string `json:"dynamicParams,omitempty"` + RouteParams []routeParamJSON `json:"routeParams,omitempty"` BindingStatus source.BackendBindingStatus `json:"bindingStatus,omitempty"` Signature source.BackendSignatureKind `json:"signature,omitempty"` InputType string `json:"inputType,omitempty"` @@ -171,6 +173,8 @@ func siteMapEndpoints(endpoints []compiler.EndpointBinding) []SiteMapEndpoint { PageID: endpoint.PageID, Symbol: endpoint.Symbol, Package: endpoint.Package, + DynamicParams: append([]string(nil), endpoint.DynamicParams...), + RouteParams: routeParamsJSON(endpoint.RouteParams), BindingStatus: endpoint.BindingStatus, Signature: endpoint.BindingSignature, InputType: endpoint.BindingInputType, diff --git a/internal/source/source.go b/internal/source/source.go index ae6afca6..b929e09e 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -177,6 +177,140 @@ func ValidateBackendRoutePath(value string) error { return nil } +// ValidateBackendRoutePattern rejects unsafe generated route patterns while +// allowing whole-segment route params such as /patients/{id:int}. +func ValidateBackendRoutePattern(value string) error { + if strings.TrimSpace(value) != value { + return fmt.Errorf("endpoint path %q must not contain surrounding whitespace", value) + } + if value == "" { + return fmt.Errorf("endpoint path must not be empty") + } + if !strings.HasPrefix(value, "/") { + return fmt.Errorf("endpoint path %q must be a local absolute path", value) + } + if strings.HasPrefix(value, "//") { + return fmt.Errorf("endpoint path %q must not be protocol-relative", value) + } + if strings.Contains(value, "\\") { + return fmt.Errorf("endpoint path %q must not contain backslashes", value) + } + if strings.ContainsAny(value, "?#") { + return fmt.Errorf("endpoint path %q must not contain query strings or fragments", value) + } + for _, char := range value { + if char < 0x20 || char == 0x7f { + return fmt.Errorf("endpoint path %q must not contain control characters", value) + } + } + cleaned := BackendRoutePath(value) + if cleaned != value { + return fmt.Errorf("endpoint path %q must be a clean absolute path without dot segments, duplicate slashes, or trailing slash", value) + } + if value == "/" { + return nil + } + + seen := map[string]bool{} + segments := strings.Split(strings.TrimPrefix(value, "/"), "/") + for index, segment := range segments { + if !strings.ContainsAny(segment, "{}") { + continue + } + param, ok := backendRouteParamSegment(segment) + if !ok { + return fmt.Errorf("endpoint path %q has invalid route parameter segment %q; use {name} or {name:type} as the whole segment", value, segment) + } + if strings.HasSuffix(param.name, "?") { + return fmt.Errorf("endpoint path %q uses optional route parameter %q; optional route parameters are not supported", value, segment) + } + if param.rest && param.name == "" { + return fmt.Errorf("endpoint path %q has rest route parameter segment %q without a name; declare it as {name...}", value, segment) + } + if !param.rest && strings.Contains(param.name, ".") { + return fmt.Errorf("endpoint path %q has invalid route parameter segment %q; rest route parameters use exactly three dots, such as {name...}", value, segment) + } + if !isBackendRouteParamName(param.name) { + return fmt.Errorf("endpoint path %q has invalid route parameter name %q", value, param.name) + } + if param.rest && param.hasType { + return fmt.Errorf("endpoint path %q declares typed rest route parameter %q; rest route parameters are always strings", value, segment) + } + if !isBackendRouteParamType(param.typ) { + return fmt.Errorf("endpoint path %q has invalid route parameter type %q for %q; supported types are string, int, int64, uint, uint64, bool, float64", value, param.typ, param.name) + } + if param.rest && index != len(segments)-1 { + return fmt.Errorf("endpoint path %q declares rest route parameter {%s...} before the end of the route; rest parameters must be the last segment", value, param.name) + } + if seen[param.name] { + return fmt.Errorf("endpoint path %q repeats route parameter %q", value, param.name) + } + seen[param.name] = true + } + return nil +} + +type backendRouteParam struct { + name string + typ string + rest bool + hasType bool +} + +func backendRouteParamSegment(segment string) (backendRouteParam, bool) { + if !strings.HasPrefix(segment, "{") || !strings.HasSuffix(segment, "}") { + return backendRouteParam{}, false + } + if strings.Count(segment, "{") != 1 || strings.Count(segment, "}") != 1 { + return backendRouteParam{}, false + } + value := strings.TrimSuffix(strings.TrimPrefix(segment, "{"), "}") + name, paramType, found := strings.Cut(value, ":") + if !found { + paramType = "string" + } + rest := strings.HasSuffix(name, "...") + if rest { + name = strings.TrimSuffix(name, "...") + } + return backendRouteParam{name: name, typ: paramType, rest: rest, hasType: found}, true +} + +func isBackendRouteParamType(value string) bool { + switch value { + case "string", "int", "int64", "uint", "uint64", "bool", "float64": + return true + default: + return false + } +} + +func isBackendRouteParamName(value string) bool { + if value == "" { + return false + } + for index, r := range value { + if index == 0 { + if !isASCIIIdentifierStart(r) { + return false + } + continue + } + if !isASCIIIdentifierPart(r) { + return false + } + } + return true +} + +func isASCIIIdentifierStart(r rune) bool { + return r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') +} + +func isASCIIIdentifierPart(r rune) bool { + return isASCIIIdentifierStart(r) || (r >= '0' && r <= '9') +} + // BackendBindingStatus describes whether a .gwdk backend block has a matching // same-package Go handler. type BackendBindingStatus string diff --git a/internal/source/source_test.go b/internal/source/source_test.go index 44cd2b47..4720b214 100644 --- a/internal/source/source_test.go +++ b/internal/source/source_test.go @@ -38,6 +38,43 @@ func TestValidateBackendRoutePath(t *testing.T) { } } +func TestValidateBackendRoutePattern(t *testing.T) { + valid := []string{ + "/", + "/patients", + "/patients/{id}", + "/patients/{id:int}", + "/docs/{path...}", + } + for _, path := range valid { + if err := ValidateBackendRoutePattern(path); err != nil { + t.Fatalf("expected %q to be valid, got %v", path, err) + } + } + + invalid := []string{ + "", + "patients", + "//example.com/pay", + "/patients?filter=active", + "/patients#form", + "/patients/{id:uuid}", + "/patients/{id}/{id}", + "/docs/{path...}/edit", + "/docs/{path...:int}", + "/patients/{id?}", + "/patients/{id", + "/patients/{id}/", + "/patients//{id}", + "/patients/../{id}", + } + for _, path := range invalid { + if err := ValidateBackendRoutePattern(path); err == nil { + t.Fatalf("expected %q to be invalid", path) + } + } +} + func TestPositionAtAndOffsetOf(t *testing.T) { // Multi-line, multi-byte (the euro sign is 3 bytes) so rune columns and byte // offsets diverge. diff --git a/runtime/app/app_test.go b/runtime/app/app_test.go index 584996e4..b6fe0194 100644 --- a/runtime/app/app_test.go +++ b/runtime/app/app_test.go @@ -707,6 +707,34 @@ func TestBackendRouterDispatchesNormalizedRoutes(t *testing.T) { } } +func TestBackendRouterDispatchesDynamicRoutes(t *testing.T) { + router, err := NewBackendRouter(BackendRoute{ + Method: http.MethodGet, + Path: "/patients/{id:int}/vitals", + Kind: "fragment", + Handler: func(writer http.ResponseWriter, request *http.Request) bool { + writer.WriteHeader(http.StatusAccepted) + return true + }, + }) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/patients/42/vitals", nil) + + if !router.Dispatch(recorder, request) { + t.Fatal("expected dynamic route to dispatch") + } + if recorder.Code != http.StatusAccepted { + t.Fatalf("unexpected status: %d", recorder.Code) + } + + if router.Dispatch(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/patients/42/edit", nil)) { + t.Fatal("did not expect different dynamic shape to dispatch") + } +} + func TestBackendRouterHidesOrdinaryHandlerErrorDetails(t *testing.T) { router, err := NewBackendRouter(BackendRoute{ Method: http.MethodGet, diff --git a/runtime/app/backend.go b/runtime/app/backend.go index 59d20428..68c9b42a 100644 --- a/runtime/app/backend.go +++ b/runtime/app/backend.go @@ -9,6 +9,7 @@ import ( "github.com/cssbruno/gowdk/runtime/form" "github.com/cssbruno/gowdk/runtime/response" + gowdkroute "github.com/cssbruno/gowdk/runtime/route" ) // DefaultActionBodyLimit is the generated action request body limit. @@ -31,9 +32,10 @@ type BackendRoute struct { Handler BackendHandler } -// BackendRouter dispatches exact generated backend routes. +// BackendRouter dispatches generated backend routes. type BackendRouter struct { - routes map[backendRouteKey]backendRouteEntry + routes map[backendRouteKey]backendRouteEntry + patterns []backendPatternRouteEntry } type backendRouteKey struct { @@ -46,6 +48,12 @@ type backendRouteEntry struct { handler BackendHandler } +type backendPatternRouteEntry struct { + key backendRouteKey + kind string + handler BackendHandler +} + // NewBackendRouter creates a backend router and registers the supplied routes. func NewBackendRouter(routes ...BackendRoute) (*BackendRouter, error) { router := &BackendRouter{routes: map[backendRouteKey]backendRouteEntry{}} @@ -77,6 +85,15 @@ func (router *BackendRouter) handle(kind string, method string, routePath string return fmt.Errorf("backend route %s %s handler is required", method, routePath) } key := backendRouteKey{method: method, path: normalizeBackendPath(routePath)} + if backendRouteIsDynamic(key.path) { + for _, route := range router.patterns { + if route.key == key { + return fmt.Errorf("duplicate backend route %s %s", key.method, key.path) + } + } + router.patterns = append(router.patterns, backendPatternRouteEntry{key: key, kind: strings.ToLower(strings.TrimSpace(kind)), handler: BackendBoundary(kind, handler)}) + return nil + } if _, exists := router.routes[key]; exists { return fmt.Errorf("duplicate backend route %s %s", key.method, key.path) } @@ -99,12 +116,13 @@ func (router *BackendRouter) Dispatch(writer http.ResponseWriter, request *http. if router == nil || request == nil { return false } - route := router.routes[backendRouteKey{ + key := backendRouteKey{ method: strings.ToUpper(strings.TrimSpace(request.Method)), path: normalizeBackendPath(request.URL.Path), - }] + } + route := router.routes[key] if route.handler == nil { - return false + return router.dispatchPattern(writer, request, key.method) } if route.kind == "query" && !isContractQueryRequest(request) { return false @@ -112,6 +130,26 @@ func (router *BackendRouter) Dispatch(writer http.ResponseWriter, request *http. return route.handler(writer, request) } +func (router *BackendRouter) dispatchPattern(writer http.ResponseWriter, request *http.Request, method string) bool { + for _, route := range router.patterns { + if route.key.method != method { + continue + } + if route.kind == "query" && !isContractQueryRequest(request) { + continue + } + if _, ok := gowdkroute.Match(route.key.path, request.URL.Path); !ok { + continue + } + return route.handler(writer, request) + } + return false +} + +func backendRouteIsDynamic(routePath string) bool { + return strings.Contains(routePath, "{") && strings.Contains(routePath, "}") +} + func isContractQueryRequest(request *http.Request) bool { if request == nil { return false diff --git a/runtime/guard/guard_test.go b/runtime/guard/guard_test.go index f4af89c5..72768fcf 100644 --- a/runtime/guard/guard_test.go +++ b/runtime/guard/guard_test.go @@ -4,10 +4,12 @@ import ( "context" "errors" "net/http" + "net/http/httptest" "strings" "testing" gowdkauth "github.com/cssbruno/gowdk/runtime/auth" + gowdkresponse "github.com/cssbruno/gowdk/runtime/response" ) func TestNewContextCarriesRequestContext(t *testing.T) { @@ -90,3 +92,66 @@ func TestRunGuardsWithAuthFailsClosedForNativeRBACGuards(t *testing.T) { t.Fatalf("expected forbidden error, got %v", err) } } + +func TestGuardRedirectResponseHelpers(t *testing.T) { + err := RunGuards(Context{}, []string{"auth.required"}, Registry{ + "auth.required": func(Context) error { return RedirectTo("/login?next=/dashboard") }, + }) + result, ok := ResponseResult(err) + if !ok || result.Kind != gowdkresponse.Redirect || result.Status != http.StatusSeeOther || result.URL != "/login?next=/dashboard" { + t.Fatalf("unexpected guard redirect response: %#v ok=%v err=%v", result, ok, err) + } + + for _, url := range []string{"https://example.com/login", "//example.com/login", "/\\evil.com", "/login\nSet-Cookie: bad=1"} { + if _, ok := ResponseResult(Redirect(url, http.StatusSeeOther)); ok { + t.Fatalf("unsafe guard redirect %q should not produce a response", url) + } + if _, ok := ResponseResult(RedirectError{URL: url, Status: http.StatusFound}); ok { + t.Fatalf("unsafe manually constructed guard redirect %q should not produce a response", url) + } + if _, ok := ResponseResult(&RedirectError{URL: url, Status: http.StatusFound}); ok { + t.Fatalf("unsafe manually constructed pointer guard redirect %q should not produce a response", url) + } + } + if _, ok := ResponseResult(Redirect("/login", http.StatusOK)); ok { + t.Fatal("non-redirect guard status should not produce a response") + } + if _, ok := ResponseResult(RedirectError{URL: "/login", Status: http.StatusOK}); ok { + t.Fatal("manually constructed non-redirect guard status should not produce a response") + } + + err = RunGuards(Context{}, []string{"auth.required"}, Registry{ + "auth.required": func(Context) error { + return Respond(gowdkresponse.JSONBody(http.StatusUnauthorized, `{"error":"login required"}`)) + }, + }) + result, ok = ResponseResult(err) + if !ok || result.Kind != gowdkresponse.JSON || result.Status != http.StatusUnauthorized { + t.Fatalf("unexpected guard custom response: %#v ok=%v err=%v", result, ok, err) + } + + result, ok = ResponseResult(&ResponseError{Result: gowdkresponse.JSONBody(http.StatusUnauthorized, `{"error":"login required"}`)}) + if !ok || result.Kind != gowdkresponse.JSON || result.Status != http.StatusUnauthorized { + t.Fatalf("unexpected pointer guard custom response: %#v ok=%v", result, ok) + } +} + +func TestWriteNoStoreFailure(t *testing.T) { + redirect := httptest.NewRecorder() + WriteNoStoreFailure(redirect, RedirectTo("/login")) + if redirect.Code != http.StatusSeeOther || redirect.Header().Get("Location") != "/login" || redirect.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("unexpected redirect failure response: status=%d headers=%v", redirect.Code, redirect.Header()) + } + + jsonResponse := httptest.NewRecorder() + WriteNoStoreFailure(jsonResponse, Respond(gowdkresponse.JSONBody(http.StatusUnauthorized, `{"error":"login required"}`))) + if jsonResponse.Code != http.StatusUnauthorized || jsonResponse.Header().Get("Cache-Control") != "no-store" || !strings.Contains(jsonResponse.Body.String(), "login required") { + t.Fatalf("unexpected JSON failure response: status=%d headers=%v body=%q", jsonResponse.Code, jsonResponse.Header(), jsonResponse.Body.String()) + } + + ordinary := httptest.NewRecorder() + WriteNoStoreFailure(ordinary, errors.New("guard failed")) + if ordinary.Code != http.StatusForbidden || ordinary.Header().Get("Cache-Control") != "no-store" || !strings.Contains(ordinary.Body.String(), "guard failed") { + t.Fatalf("unexpected ordinary failure response: status=%d headers=%v body=%q", ordinary.Code, ordinary.Header(), ordinary.Body.String()) + } +} diff --git a/runtime/guard/response.go b/runtime/guard/response.go new file mode 100644 index 00000000..b1b93d16 --- /dev/null +++ b/runtime/guard/response.go @@ -0,0 +1,120 @@ +package guard + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/cssbruno/gowdk/runtime/response" +) + +// RedirectError asks generated guard handling to issue a safe local redirect. +type RedirectError struct { + URL string + Status int +} + +func (err RedirectError) Error() string { + if err.URL == "" { + return "guard redirect" + } + return "guard redirect to " + err.URL +} + +// ResponseError asks generated guard handling to write a runtime response. +type ResponseError struct { + Result response.Response +} + +func (err ResponseError) Error() string { + return "guard response" +} + +// RedirectTo returns an error that generated guard handling translates into a +// no-store local redirect. +func RedirectTo(url string) error { + return Redirect(url, http.StatusSeeOther) +} + +// Redirect returns an error that generated guard handling translates into a +// no-store local redirect with the provided 3xx status. +func Redirect(url string, status int) error { + if err := validateRedirectURL(url); err != nil { + return err + } + if status < 300 || status > 399 { + return fmt.Errorf("guard redirect status must be 3xx") + } + return RedirectError{URL: url, Status: status} +} + +// Respond returns an error that generated guard handling translates into a +// no-store runtime response. +func Respond(result response.Response) error { + return ResponseError{Result: result} +} + +// ResponseResult extracts a generated guard response error. +func ResponseResult(err error) (response.Response, bool) { + var redirect RedirectError + if errors.As(err, &redirect) { + return redirectResponseResult(redirect) + } + var redirectPtr *RedirectError + if errors.As(err, &redirectPtr) && redirectPtr != nil { + return redirectResponseResult(*redirectPtr) + } + var result ResponseError + if errors.As(err, &result) { + return result.Result, true + } + var resultPtr *ResponseError + if errors.As(err, &resultPtr) && resultPtr != nil { + return resultPtr.Result, true + } + return response.Response{}, false +} + +func redirectResponseResult(redirect RedirectError) (response.Response, bool) { + status := redirect.Status + if status == 0 { + status = http.StatusSeeOther + } + if status < 300 || status > 399 { + return response.Response{}, false + } + if validateRedirectURL(redirect.URL) != nil { + return response.Response{}, false + } + return response.Response{Kind: response.Redirect, Status: status, URL: redirect.URL}, true +} + +// WriteNoStoreFailure writes a generated guard failure response. Ordinary +// guard errors fail closed with 403; guard response helpers keep the same +// no-store cache policy while allowing explicit redirects or response shapes. +func WriteNoStoreFailure(writer http.ResponseWriter, err error) { + if result, ok := ResponseResult(err); ok { + _ = response.WriteNoStoreHTTP(writer, result) + return + } + response.WriteNoStoreError(writer, http.StatusForbidden, err.Error()) +} + +func validateRedirectURL(url string) error { + if url == "" || url[0] != '/' { + return fmt.Errorf("guard redirect %q must be a local absolute path", url) + } + if len(url) > 1 && (url[1] == '/' || url[1] == '\\') { + return fmt.Errorf("guard redirect %q must not be protocol-relative", url) + } + // Browsers normalize "\" to "/" before navigating, so "/\evil.com" is + // treated like the protocol-relative "//evil.com". + if strings.Contains(url, "\\") { + return fmt.Errorf("guard redirect %q must not contain backslashes", url) + } + if strings.ContainsAny(url, "\r\n") { + return fmt.Errorf("guard redirect %q must not contain newlines", url) + } + return nil +}