Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 33 additions & 17 deletions internal/compiler/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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" &&
Expand Down
50 changes: 50 additions & 0 deletions internal/compiler/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3572,6 +3572,16 @@ func TestValidateManifestRejectsInvalidContractReferenceRoutes(t *testing.T) {
viewBody: `<form method="post" action="/patients/{id}" g:command="patients.CreatePatient"></form>`,
message: "without query, fragment, or params",
},
{
name: "trailing slash command action",
viewBody: `<form method="post" action="/patients/" g:command="patients.CreatePatient"></form>`,
message: "clean absolute path",
},
{
name: "relative command action",
viewBody: `<form method="post" action="patients" g:command="patients.CreatePatient"></form>`,
message: "local absolute path",
},
{
name: "unsupported command method",
viewBody: `<form method="get" action="/patients" g:command="patients.CreatePatient"></form>`,
Expand Down Expand Up @@ -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: `<main>
<form method="post" action="/patients" g:command="patients.CreatePatient"></form>
<form method="post" action="/patients" g:command="patients.CreatePatient"></form>
</main>`,
},
}},
}

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{{
Expand Down Expand Up @@ -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: `<main>
<section g:query="patients.ListPatients"></section>
<section g:query="patients.ListPatients"></section>
</main>`,
},
}},
}

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) {
Expand Down
8 changes: 7 additions & 1 deletion internal/source/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
13 changes: 13 additions & 0 deletions internal/source/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}