Skip to content

Commit

Permalink
feat: add support for ingress path params
Browse files Browse the repository at this point in the history
  • Loading branch information
wesbillman committed Nov 25, 2023
1 parent f3fb34d commit 02135eb
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 24 deletions.
31 changes: 13 additions & 18 deletions backend/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func New(ctx context.Context, db *dal.DAL, config Config, runnerScaling scaling.
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
logger := log.FromContext(r.Context())
logger.Infof("%s %s", r.Method, r.URL.Path)
routes, err := s.dal.GetIngressRoutes(r.Context(), r.Method, r.URL.Path)
route, err := s.getIngressRoute(r.Context(), r.Method, r.URL.Path)
if err != nil {
if errors.Is(err, dal.ErrNotFound) {
http.NotFound(w, r)
Expand All @@ -173,7 +173,15 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
route := routes[rand.Intn(len(routes))] //nolint:gosec

pathParams, err := getPathParams(route.Path, r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

logger.Infof("Path params: %v", pathParams)

var body []byte
switch r.Method {
case http.MethodPost, http.MethodPut:
Expand Down Expand Up @@ -201,8 +209,9 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
creq := connect.NewRequest(&ftlv1.CallRequest{
Verb: &schemapb.VerbRef{Module: route.Module, Name: route.Verb},
Body: body,
Metadata: &ftlv1.Metadata{},
Verb: &schemapb.VerbRef{Module: route.Module, Name: route.Verb},
Body: body,
})
headers.SetRequestName(creq.Header(), requestName)
resp, err := s.Call(r.Context(), creq)
Expand Down Expand Up @@ -630,20 +639,6 @@ func (s *Service) Call(ctx context.Context, req *connect.Request[ftlv1.CallReque
return resp, nil
}

func (s *Service) getRoutesForModule(module string) ([]dal.Route, error) {
var routes []dal.Route
var ok bool
allRoutes, err := s.dal.GetRoutingTable(context.Background(), []string{module})
if err != nil {
return nil, errors.WithStack(err)
}
routes, ok = allRoutes[module]
if !ok {
return nil, connect.NewError(connect.CodeNotFound, errors.Errorf("no runners for module %q", module))
}
return routes, nil
}

func (s *Service) GetArtefactDiffs(ctx context.Context, req *connect.Request[ftlv1.GetArtefactDiffsRequest]) (*connect.Response[ftlv1.GetArtefactDiffsResponse], error) {
byteDigests, err := slices.MapErr(req.Msg.ClientDigests, sha256.ParseSHA256)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions backend/controller/dal/dal.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var (
type IngressRoute struct {
Runner model.RunnerKey
Endpoint string
Path string
Module string
Verb string
}
Expand Down Expand Up @@ -947,6 +948,7 @@ func (d *DAL) GetIngressRoutes(ctx context.Context, method string, path string)
return IngressRoute{
Runner: model.RunnerKey(row.RunnerKey),
Endpoint: row.Endpoint,
Path: row.Path,
Module: row.Module,
Verb: row.Verb,
}
Expand Down
66 changes: 66 additions & 0 deletions backend/controller/ingress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package controller

import (
"context"
"math/rand"
"strings"

"github.com/TBD54566975/ftl/backend/common/slices"
"github.com/TBD54566975/ftl/backend/controller/dal"
"github.com/alecthomas/errors"
)

func (s *Service) getIngressRoute(ctx context.Context, method string, path string) (*dal.IngressRoute, error) {
pathStart := strings.Split(path, "/")[1]
routes, err := s.dal.GetIngressRoutes(ctx, method, "/"+pathStart)
if err != nil {
return nil, err
}
matchedRoutes := slices.Filter(routes, func(route dal.IngressRoute) bool {
return matchURL(route.Path, path)
})
if len(matchedRoutes) == 0 {
return nil, dal.ErrNotFound
}

// TODO: add load balancing at some point
route := matchedRoutes[rand.Intn(len(matchedRoutes))] //nolint:gosec
return &route, nil
}

func getPathParams(pattern, urlPath string) (map[string]string, error) {
formatSegments := strings.Split(strings.Trim(pattern, "/"), "/")
urlSegments := strings.Split(strings.Trim(urlPath, "/"), "/")

if len(formatSegments) != len(urlSegments) {
return nil, errors.New("number of segments in pattern and URL path don't match")
}

params := make(map[string]string)
for i, segment := range formatSegments {
if strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") {
key := strings.Trim(segment, "{}")
params[key] = urlSegments[i]
}
}
return params, nil
}

func matchURL(pattern, urlPath string) bool {
patternSegments := strings.Split(pattern, "/")
urlSegments := strings.Split(urlPath, "/")

if len(patternSegments) != len(urlSegments) {
return false
}

for i := range patternSegments {
if strings.HasPrefix(patternSegments[i], "{") && strings.HasSuffix(patternSegments[i], "}") {
continue // This is a dynamic segment; skip exact match check
}
if patternSegments[i] != urlSegments[i] {
return false
}
}
return true
}
86 changes: 86 additions & 0 deletions backend/controller/ingress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package controller

import (
"testing"

"github.com/alecthomas/errors"
)

func TestMatchURL(t *testing.T) {
tests := []struct {
pattern string
urlPath string
expected bool
}{
{"", "", true},
{"/", "/", true},
{"/users", "/users", true},
{"/users/{id}", "/users/123", true},
{"/users/{id}/posts", "/users/123/posts", true},
{"/users/{id}/posts/{postID}", "/users/123/posts/456", true},
{"/users/{id}/posts/{postID}", "/users/123/posts", false},
{"/users/{id}/posts/{postID}", "/users/123/posts/456/comments", false},
{"/users/{id}/posts/{postID}/comments", "/users/123/posts/456/comments", true},
{"/users/{id}/posts/{postID}/comments/{commentID}", "/users/123/posts/456/comments/789", true},
{"/users/{id}/posts/{postID}/comments/{commentID}", "/users/123/posts/456/comments", false},
{"/users/{id}/posts/{postID}/comments/{commentID}", "/users/123/posts/456/comments/789/replies", false},
{"/users/{id}/posts/{postID}/comments/{commentID}/replies", "/users/123/posts/456/comments/789/replies", true},
{"/users/{id}/posts/{postID}/comments/{commentID}/replies/{replyID}", "/users/123/posts/456/comments/789/replies/987", true},
{"/users/{id}/posts/{postID}/comments/{commentID}/replies/{replyID}", "/users/123/posts/456/comments/789/replies", false},
{"/users/{id}/posts/{postID}/comments/{commentID}/replies/{replyID}", "/users/123/posts/456/comments/789/replies/987/extra", false},
}

for _, test := range tests {
actual := matchURL(test.pattern, test.urlPath)
if actual != test.expected {
t.Errorf("matchURL(%q, %q) = %v, expected %v", test.pattern, test.urlPath, actual, test.expected)
}
}
}

func TestGetPathParams(t *testing.T) {
segmentsError := errors.New("number of segments in pattern and URL path don't match")

tests := []struct {
pattern string
urlPath string
expected map[string]string
err error
}{
{"", "", map[string]string{}, nil},
{"/", "/", map[string]string{}, nil},
{"/users", "/users", map[string]string{}, nil},
{"/users/{id}", "/users/123", map[string]string{"id": "123"}, nil},
{"/users/{id}/posts", "/users/123/posts", map[string]string{"id": "123"}, nil},
{"/users/{id}/posts/{postID}", "/users/123/posts/456", map[string]string{"id": "123", "postID": "456"}, nil},
{"/users/{id}/posts/{postID}", "/users/123/posts", nil, segmentsError},
{"/users/{id}/posts/{postID}", "/users/123/posts/456/comments/", nil, segmentsError},
{"/users/{id}/posts/{postID}/comments", "/users/123/posts/456/comments", map[string]string{"id": "123", "postID": "456"}, nil},
{"/users/{id}/posts/{postID}/comments/{commentID}", "/users/123/posts/456/comments/789", map[string]string{"id": "123", "postID": "456", "commentID": "789"}, nil},
{"/users/{id}/posts/{postID}/comments/{commentID}", "/users/123/posts/456/comments", nil, segmentsError},
{"/users/{id}/posts/{year}/{month}/{day}", "/users/123/posts/2023/11/12", map[string]string{"id": "123", "year": "2023", "month": "11", "day": "12"}, nil},
}

for _, test := range tests {
actual, err := getPathParams(test.pattern, test.urlPath)
if !areMapsEqual(actual, test.expected) {
t.Errorf("getPathParams(%q, %q) = %v, expected %v", test.pattern, test.urlPath, actual, test.expected)
}
if (err != nil && test.err == nil) || (err == nil && test.err != nil) || (err != nil && test.err != nil && err.Error() != test.err.Error()) {
t.Errorf("getPathParams(%q, %q) returned error %v, expected %v", test.pattern, test.urlPath, err, test.err)
}
}
}

// Helper function to compare two maps
func areMapsEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
if bValue, ok := b[key]; !ok || bValue != value {
return false
}
}
return true
}
6 changes: 3 additions & 3 deletions backend/controller/sql/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,12 @@ VALUES ((SELECT id FROM deployments WHERE name = $1 LIMIT 1), $2, $3, $4, $5);

-- name: GetIngressRoutes :many
-- Get the runner endpoints corresponding to the given ingress route.
SELECT r.key AS runner_key, endpoint, ir.module, ir.verb
SELECT r.key AS runner_key, endpoint, ir.path, ir.module, ir.verb
FROM ingress_routes ir
INNER JOIN runners r ON ir.deployment_id = r.deployment_id
WHERE r.state = 'assigned'
AND ir.method = $1
AND ir.path = $2;
AND ir.path LIKE CONCAT(sqlc.arg('path')::TEXT, '%');

-- name: GetAllIngressRoutes :many
SELECT d.name AS deployment_name, ir.module, ir.verb, ir.method, ir.path
Expand All @@ -384,4 +384,4 @@ INSERT INTO events (deployment_id, request_id, type,
custom_key_1, custom_key_2, custom_key_3, custom_key_4,
payload)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id;
RETURNING id;
6 changes: 4 additions & 2 deletions backend/controller/sql/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type GetRequest struct {
}

//ftl:verb
//ftl:ingress GET /productcatalog/id
//ftl:ingress GET /productcatalog/{id}/date/{year}/{month}/{day}
func Get(ctx context.Context, req GetRequest) (Product, error) {
for _, p := range database {
if p.ID == req.ID {
Expand Down

0 comments on commit 02135eb

Please sign in to comment.