diff --git a/internal/compiler/routes.go b/internal/compiler/routes.go index cde14a9..89c76db 100644 --- a/internal/compiler/routes.go +++ b/internal/compiler/routes.go @@ -183,6 +183,9 @@ func validateRouteMethodConflicts(pages []gwdkir.Page, endpoints []gwdkir.GoEndp if allowedPageOwnedQueryRouteConflict(previous, registration) { continue } + if identicalContractRouteRegistration(previous, registration) { + continue + } diagnostics = append(diagnostics, ValidationError{ Code: "route_method_conflict", PageID: registration.PageID, @@ -203,14 +206,15 @@ func validateRouteMethodConflicts(pages []gwdkir.Page, endpoints []gwdkir.GoEndp } type routeRegistration struct { - Kind string - Owner string - Method string - Route string - Pattern string - PageID string - Source string - Span source.SourceSpan + Kind string + Owner string + Method string + Route string + Pattern string + Contract string + PageID string + Source string + Span source.SourceSpan } func routeRegistrations(pages []gwdkir.Page, endpoints []gwdkir.GoEndpoint, refs []gwdkir.ContractReference) []routeRegistration { @@ -334,7 +338,8 @@ func routeRegistrations(pages []gwdkir.Page, endpoints []gwdkir.GoEndpoint, refs if err := source.ValidateBackendRoutePath(ref.Path); err != nil { continue } - info, issues := parseRoute(ref.Path) + route := source.BackendRoutePath(ref.Path) + info, issues := parseRoute(route) if len(issues) > 0 { continue } @@ -344,14 +349,15 @@ func routeRegistrations(pages []gwdkir.Page, endpoints []gwdkir.GoEndpoint, refs } method := strings.ToUpper(strings.TrimSpace(ref.Method)) registrations = append(registrations, routeRegistration{ - Kind: "contract_" + kind, - Owner: fmt.Sprintf("%s contract %s", kind, ref.Name), - Method: method, - Route: ref.Path, - Pattern: info.Pattern, - PageID: ref.OwnerID, - Source: ref.Source, - Span: ref.Span, + Kind: "contract_" + kind, + Owner: fmt.Sprintf("%s contract %s", kind, ref.Name), + Method: method, + Route: route, + Pattern: info.Pattern, + Contract: ref.Name, + PageID: ref.OwnerID, + Source: ref.Source, + Span: ref.Span, }) } return registrations @@ -361,6 +367,16 @@ func allowedPageOwnedQueryRouteConflict(first routeRegistration, current routeRe return pageOwnedQueryRouteConflict(first, current) || pageOwnedQueryRouteConflict(current, first) } +func identicalContractRouteRegistration(first routeRegistration, current routeRegistration) bool { + if !strings.HasPrefix(first.Kind, "contract_") || first.Kind != current.Kind { + return false + } + return first.Method == current.Method && + first.Route == current.Route && + first.Contract == current.Contract && + first.PageID == current.PageID +} + func pageOwnedQueryRouteConflict(page routeRegistration, query routeRegistration) bool { return page.Kind == "page" && query.Kind == "contract_query" && diff --git a/internal/compiler/validate_test.go b/internal/compiler/validate_test.go index 0e8cd7e..29630cf 100644 --- a/internal/compiler/validate_test.go +++ b/internal/compiler/validate_test.go @@ -3572,6 +3572,16 @@ func TestValidateManifestRejectsInvalidContractReferenceRoutes(t *testing.T) { viewBody: `
`, message: "without query, fragment, or params", }, + { + name: "trailing slash command action", + viewBody: `
`, + message: "clean absolute path", + }, + { + name: "relative command action", + viewBody: `
`, + message: "local absolute path", + }, { name: "unsupported command method", viewBody: `
`, @@ -3835,6 +3845,26 @@ func TestValidateManifestRejectsRouteMethodConflicts(t *testing.T) { } }) + t.Run("identical command route references are allowed", func(t *testing.T) { + app := appFixture{ + Pages: []gwdkir.Page{{ + ID: "patients", + Route: "/patients", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
+
+
+
`, + }, + }}, + } + + if err := validateManifest(gowdk.Config{}, app); err != nil { + t.Fatalf("expected identical command route references to be valid, got %v", err) + } + }) + t.Run("query route conflicts with api", func(t *testing.T) { app := appFixture{ Pages: []gwdkir.Page{{ @@ -3882,6 +3912,26 @@ func TestValidateManifestRejectsRouteMethodConflicts(t *testing.T) { t.Fatalf("Missing duplicate query route_method_conflict diagnostic: %#v", diagnostics) } }) + + t.Run("identical query route references are allowed", func(t *testing.T) { + app := appFixture{ + Pages: []gwdkir.Page{{ + ID: "patients", + Route: "/patients", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
+
+
+
`, + }, + }}, + } + + if err := validateManifest(gowdk.Config{}, app); err != nil { + t.Fatalf("expected identical query route references to be valid, got %v", err) + } + }) } func TestValidateManifestAllowsSameRouteWithDifferentMethods(t *testing.T) { diff --git a/internal/source/source.go b/internal/source/source.go index 731c3fa..5bfec55 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -67,6 +67,12 @@ func BackendRouteMethod(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } +// BackendRoutePath returns the normalized path key used by generated backend +// routers after ValidateBackendRoutePath has accepted the source value. +func BackendRoutePath(value string) string { + return path.Clean("/" + value) +} + // ValidateBackendRoutePath rejects paths that would be unsafe or ambiguous when // registered as generated backend routes. func ValidateBackendRoutePath(value string) error { @@ -93,7 +99,7 @@ func ValidateBackendRoutePath(value string) error { return fmt.Errorf("endpoint path %q must not contain control characters", value) } } - cleaned := path.Clean(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) } diff --git a/internal/source/source_test.go b/internal/source/source_test.go index c0b858a..09b20f5 100644 --- a/internal/source/source_test.go +++ b/internal/source/source_test.go @@ -43,3 +43,16 @@ func TestBackendRouteMethod(t *testing.T) { t.Fatalf("expected normalized method POST, got %q", got) } } + +func TestBackendRoutePath(t *testing.T) { + tests := map[string]string{ + "/patients": "/patients", + "/patients/": "/patients", + "patients": "/patients", + } + for input, want := range tests { + if got := BackendRoutePath(input); got != want { + t.Fatalf("expected %q to normalize to %q, got %q", input, want, got) + } + } +}