-
Notifications
You must be signed in to change notification settings - Fork 56
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
Fix: user-defined field aliases may break federation #94
Conversation
Codecov Report
@@ Coverage Diff @@
## main #94 +/- ##
==========================================
+ Coverage 67.15% 67.41% +0.25%
==========================================
Files 24 24
Lines 2749 2731 -18
==========================================
- Hits 1846 1841 -5
+ Misses 748 740 -8
+ Partials 155 150 -5
Continue to review full report at Codecov.
|
@@ -844,11 +818,19 @@ func TestQueryPlanNoUnnessecaryID(t *testing.T) { | |||
{ | |||
"ServiceURL": "A", | |||
"ParentType": "Query", | |||
"SelectionSet": "{ movies { title } }", | |||
"SelectionSet": "{ movies { title _bramble_id: id } }", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this test here represented another broken scenario: the boundary type (Movie) was being requested without an id
. Digging into the planning flow, the omission was caused by a nuance in the childstep
tracking. I've eliminated the childstep
logic entirely as its sole purpose appears to have been controlling selection redundancies.
@@ -66,7 +67,7 @@ func Plan(ctx *PlanningContext) (*QueryPlan, error) { | |||
return nil, fmt.Errorf("not implemented") | |||
} | |||
|
|||
steps, err := createSteps(ctx, nil, parentType, "", ctx.Operation.SelectionSet, false) | |||
steps, err := createSteps(ctx, nil, parentType, "", ctx.Operation.SelectionSet) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've eliminated the childstep
recursion flag because its only purpose was to conditionally manage the inclusion of federation id
's – to which I suspect it was buggy about. Now that _bramble_id
is always included in boundary scopes, the complexity of childstep
can be removed.
if selection.Alias == reservedAlias && selection.Name != requiredName { | ||
return nil, nil, gqlerror.Errorf("%s.%s: alias \"%s\" is reserved for system use", strings.Join(insertionPoint, "."), reservedAlias, reservedAlias) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This loop and the reservedAliases
struct above block the submission of reserved aliases. If the user attempts to make a selection that will interfere with internal Bramble operations, we'll simply tell them it's not allowed.
if !selectionSetHasFieldNamed(selectionSet, "__typename") { | ||
selectionSet = append(selectionSet, &ast.Field{Alias: "__typename", Name: "__typename", Definition: &ast.FieldDefinition{Name: "__typename", Type: ast.NamedType("String", nil)}}) | ||
} | ||
selectionSet = append(selectionSet, &ast.Field{Alias: "__typename", Name: "__typename", Definition: &ast.FieldDefinition{Name: "__typename", Type: ast.NamedType("String", nil)}}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The selectionSetHasFieldNamed
function was buggy because it didn't account for field aliases. Rather than expanding its implementation to handle more complex naming considerations, I've opted to remove it entirely given that it was adding loops mostly for the sake of vanity selections. A __typename
field will now always be appended to fragments, even if it was also included in the user-defined selection. While potentially more verbose, it makes planning results more predictable.
id := &ast.Field{ | ||
Alias: "_id", | ||
Name: "id", | ||
Definition: parentDef.Fields.ForName("id"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This check has been greatly simplified – any boundary object with an id
definition gets a _bramble_id
hint added to its selection set. It's more verbose, but it's extremely predictable and consistent.
var reservedAliases = map[string]string{ | ||
"__typename": "__typename", | ||
"_bramble_id": "id", | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a mapping of reserved aliases – or, alias/name pairs that are sensitive to the implementation, and therefore will be validated on the gateway for integrity.
SelectionSet: []ast.Selection{&ast.Field{Alias: "_bramble_id", Name: "id", Definition: idDef}}, | ||
ObjectDefinition: implementationType, | ||
} | ||
selectionSetResult = append(selectionSetResult, possibleId) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adds the best-practice from below where the type is checked for an ID definition – I suppose the wildcard here is a namespace, which I assume is just a merged scope without an id?
@@ -264,7 +264,7 @@ var PlanTestFixture6 = &PlanTestFixture{ | |||
} | |||
|
|||
|
|||
func (f *PlanTestFixture) Plan(t *testing.T, query string) *QueryPlan { | |||
func (f *PlanTestFixture) Plan(t *testing.T, query string) (*QueryPlan, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've split these test operations apart a bit more to add an additional check for error outcomes.
@@ -357,32 +357,6 @@ func TestQueryPlanFragmentSpread1(t *testing.T) { | |||
PlanTestFixture1.Check(t, query, plan) | |||
} | |||
|
|||
func TestQueryPlanFragmentSpread1DontDuplicateTypename(t *testing.T) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test is no longer applicable now that __typename
is, by design, always appended and allowed to repeat a user-defined selection.
@@ -520,7 +520,7 @@ func mergeExecutionResultsRec(src interface{}, dst interface{}, insertionPoint [ | |||
} | |||
if srcID == dstID { | |||
for k, v := range result { | |||
if k == "_id" || k == "id" { | |||
if k == "_bramble_id" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then here's where #93 was breaking... this special case for the id
field prevented it from resolving standard GraphQL aliases. Now that everything revolves around _bramble_id
, we can leave id
alone an allow it to resolve as a normal field.
9713919
to
971b1f9
Compare
Okay, ready for review here @nmaquet. Sorry for the large PR. |
@gmac Thanks again for this one, have just had a skim so far but looks solid. One thought, if we're going the route of reserving the It would require updating the execution pipeline to look for that alias over the regular |
@lucianjon — a dedicated typename alias makes sense to me. It’d certainly be ideal to keep __typename itself as native GraphQL to be abused in any way the end user likes. |
Sweet, I think we could do it in another PR to keep the size of this one down. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good from my perspective, will give someone else the chance to have a look though.
if idDef := implementationType.Fields.ForName("id"); idDef != nil { | ||
possibleId := &ast.InlineFragment{ | ||
TypeCondition: implementationName, | ||
SelectionSet: []ast.Selection{&ast.Field{Alias: "_bramble_id", Name: "id", Definition: idDef}}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could pay to pull _bramble_id
out into a const since it's used in a few spots.
Thanks @gmac!!! |
This adds
_bramble_id
as a universal ID hint, which allowsid
to behave as a standard user-defined selection. This fixes the numerous ways to break federation with user-defined field aliases describe in #90 and #93.Apologies for the large PR – the vast majority of it is test fixtures.
The Problem
As described in #90, there are many creative ways for user-defined queries to break Bramble federation ids using field aliases, for example:
While dynamic adjustments can be made to the query planner to account for such aliases, a user-aliased id still ends up breaking in the execution resolver, per #93.
A general observation: query planning currently optimizes for vanity queries at the cost of tricky logic holes and extra looping. The process becomes simpler and more reliable when the query planner always adds consistent implementation fields for itself, even if they are redundant with the contents of the user-defined query. Not making
id
do double-duty as both a user selection and an implementation detail avoids contention between the planning and resolution steps.The fix(es)
_bramble_id: id
selection to boundary scopes. This becomes a universal implementation key, eliminating_id
and leavingid
untouched as a basic user selection._bramble_id
and__typename
aliases. A user selection will not be allowed to hijack these fields.childstep
recursion complexity andselectionSetHasFieldNamed
looping. Queries are now more verbose, but they are extremely consistent and avoid suspected logic holes.Duplicated selections are ugly
If there’s human sensitivity to duplicated field selections (machines don’t care), then I’d propose adding a single-pass loop at the end of extractSelectionSet that imposes uniqueness once based on field alias falling back to field name. At present, “selectionSetHasFieldNamed” runs for each de-duplicated field, and doesn’t take into account field aliases.
Resolves #90.
Resolves #93.