Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for ingress path params #652

Merged
merged 2 commits into from
Dec 5, 2023

Conversation

wesbillman
Copy link
Collaborator

Given an ingress path defined as

//ftl:verb
//ftl:ingress GET /productcatalog/{id}/date/{year}/{month}/{day}

We get the following from the controller

controller | info: GET /productcatalog/1234/date/2022/12/12
controller | info: Path params: map[day:12 id:1234 month:12 year:2022]

With this curl request:

curl -i http://localhost:8892/ingress/productcatalog/1234/date/2022/12/12

We can locate the record in the ingress_routes table and route to the correct verb. This needs more work and testing, but we are starting to build out the basics here to get feedback.

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, '%');
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not sure if this is good, or a bit too hacky. Open to other ideas here. Basically, I need to find routes that start with the path I'm looking for and then filter it down later based on the actual request that was made on the client.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think there's a good solution that uses the DB (or at least, not one I can think of). I think we'll just need to load the entire ingress table into memory periodically, and do the matching in code.

Comment on lines 177 to 183
pathParams := getPathParams(route.Path, r.URL.Path)
logger.Infof("Path params: %v", pathParams)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not sure where best to put these so that they can be used/retrieved from the verb code. I could add it to the ctx and have a PathParamsWithContext(ctx) or something.

@@ -201,8 +204,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{},
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I also might be able to add it to the Metadata here or something.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we'll combine the path parameters with query parameters, and map them onto the request structure. That's the approach gRPC uses, and I think it makes sense.

Copy link
Collaborator Author

@wesbillman wesbillman Nov 29, 2023

Choose a reason for hiding this comment

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

coolio! Maybe we can chat today on this one. Want to make sure we have request validation in the right place. For example, if we add a RequestParams arg that has either the path params or query params here, when we create the actual verb request, we'll need a way to know if we should use the RequestParams for the <verb>Request.

It seems like we currently put the query params into the body of the payload after we json.Marshal it. So maybe the path parameters will always be in the RequestParams struct and query params will always end up in the body? I'm guessing we can't specify query params in the ingress annotation, yeah?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Off the top of my head, I think it should be in the schema validator.

@wesbillman wesbillman force-pushed the handle-ingress-path-parameters branch from 54cf08b to 02135eb Compare November 25, 2023 01:10
Copy link
Collaborator

@alecthomas alecthomas left a comment

Choose a reason for hiding this comment

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

Looking good!

@@ -201,8 +204,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{},
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we'll combine the path parameters with query parameters, and map them onto the request structure. That's the approach gRPC uses, and I think it makes sense.

)

func (s *Service) getIngressRoute(ctx context.Context, method string, path string) (*dal.IngressRoute, error) {
pathStart := strings.Split(path, "/")[1]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Won't this be invalid if the user has something like /{foo}, or does path include the /ingress prefix?


params := make(map[string]string)
for i, segment := range formatSegments {
if strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this should be checking that the non-parameter segments match too yeah?

return params, nil
}

func matchURL(pattern, urlPath string) bool {
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be good to not have two functions doing almost the exact same thing. Perhaps just have one that takes a closure with any matching path variables, eg.

func matchURL(pattern, urlPath string, matched func(key, value)) bool

"github.com/alecthomas/errors"
)

func TestMatchURL(t *testing.T) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice.


for _, test := range tests {
actual, err := getPathParams(test.pattern, test.urlPath)
if !areMapsEqual(actual, test.expected) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use assert.Equal()

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()) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use:

if test.err != nil {
  assert.EqualError(t, test.err, err)
} else {
  assert.NoError(t, err)
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also usually put the error check before the value check.


for _, test := range tests {
actual := matchURL(test.pattern, test.urlPath)
if actual != test.expected {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use assert.Equal()

Copy link
Collaborator

Choose a reason for hiding this comment

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

There's optional printf-style formatting at the end that you can use to display a nice error eg.

assert.Equal(t, test.expected, actual, "pattern = %s, urlPath = %s", test.pattern, test.urlPath)

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, '%');
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think there's a good solution that uses the DB (or at least, not one I can think of). I think we'll just need to load the entire ingress table into memory periodically, and do the matching in code.

backend/controller/sql/queries.sql Show resolved Hide resolved
@wesbillman wesbillman force-pushed the handle-ingress-path-parameters branch 3 times, most recently from 094e145 to 64d57e1 Compare December 1, 2023 21:59
@wesbillman wesbillman marked this pull request as ready for review December 4, 2023 18:53
@wesbillman wesbillman requested a review from dhanji as a code owner December 4, 2023 18:53
@wesbillman wesbillman force-pushed the handle-ingress-path-parameters branch 2 times, most recently from 40f11eb to 859f2f8 Compare December 5, 2023 00:28
routes, err := s.dal.GetIngressRoutes(r.Context(), r.Method, r.URL.Path)
routes, err := s.dal.GetIngressRoutes(r.Context(), r.Method)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you also want the if errors.Is(err, dal.ErrNotFound) check here too.

return &route, nil
}

func matchAndExtractAllSegments(pattern, urlPath string, onMatch func(segment, value string)) bool {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be called something different? Like just matchSegments()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I like that!

return true
}

func ValidateAndExtractBody(route *dal.IngressRoute, r *http.Request, sch *schema.Schema) ([]byte, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

A doc comment here would be good.

var bodyMap map[string]any
err := json.NewDecoder(r.Body).Decode(&bodyMap)
if err != nil {
return nil, err
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think add an fmt.Errorf("HTTP request body is not valid JSON: %w", err) so the returned error is a bit more useful.

requestMap[k] = v
}
default:
// TODO: Support query params correctly for map and array
Copy link
Collaborator

Choose a reason for hiding this comment

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

TODO!!!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This pained me 😂

@wesbillman wesbillman force-pushed the handle-ingress-path-parameters branch from 2854cd5 to 47f5aab Compare December 5, 2023 03:48
@wesbillman wesbillman merged commit d9404b4 into main Dec 5, 2023
@wesbillman wesbillman deleted the handle-ingress-path-parameters branch December 5, 2023 04:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants