Skip to content

Commit 2abdde9

Browse files
authored
[Fusion] Added post-merge validation rule "ImplementedByInaccessibleRule" (#8226)
1 parent 17f505d commit 2abdde9

File tree

7 files changed

+276
-0
lines changed

7 files changed

+276
-0
lines changed

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public static class LogEntryCodes
1616
public const string ExternalUnused = "EXTERNAL_UNUSED";
1717
public const string FieldArgumentTypesNotMergeable = "FIELD_ARGUMENT_TYPES_NOT_MERGEABLE";
1818
public const string FieldWithMissingRequiredArgument = "FIELD_WITH_MISSING_REQUIRED_ARGUMENT";
19+
public const string ImplementedByInaccessible = "IMPLEMENTED_BY_INACCESSIBLE";
1920
public const string InputFieldDefaultMismatch = "INPUT_FIELD_DEFAULT_MISMATCH";
2021
public const string InputFieldTypesNotMergeable = "INPUT_FIELD_TYPES_NOT_MERGEABLE";
2122
public const string InputWithMissingRequiredFields = "INPUT_WITH_MISSING_REQUIRED_FIELDS";

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,27 @@ public static LogEntry FieldWithMissingRequiredArgument(
315315
schema);
316316
}
317317

318+
public static LogEntry ImplementedByInaccessible(
319+
MutableOutputFieldDefinition field,
320+
string typeName,
321+
string interfaceFieldName,
322+
string interfaceTypeName,
323+
MutableSchemaDefinition schema)
324+
{
325+
var coordinate = new SchemaCoordinate(typeName, field.Name);
326+
327+
return new LogEntry(
328+
string.Format(
329+
LogEntryHelper_ImplementedByInaccessible,
330+
coordinate,
331+
new SchemaCoordinate(interfaceTypeName, interfaceFieldName)),
332+
LogEntryCodes.ImplementedByInaccessible,
333+
LogSeverity.Error,
334+
coordinate,
335+
field,
336+
schema);
337+
}
338+
318339
public static LogEntry InputFieldDefaultMismatch(
319340
IValueNode defaultValueA,
320341
IValueNode defaultValueB,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using HotChocolate.Fusion.Events;
2+
using HotChocolate.Fusion.Events.Contracts;
3+
using HotChocolate.Fusion.Extensions;
4+
using HotChocolate.Types.Mutable;
5+
using static HotChocolate.Fusion.Logging.LogEntryHelper;
6+
7+
namespace HotChocolate.Fusion.PostMergeValidationRules;
8+
9+
/// <summary>
10+
/// This rule ensures that inaccessible fields (<c>@inaccessible</c>) on an object or interface type
11+
/// are not exposed through an interface. A composite type that implements an interface must provide
12+
/// public access to each field defined by the interface. If a field on an object type is marked as
13+
/// <c>@inaccessible</c> but implements an interface field that is visible in the composed schema,
14+
/// this creates a contradiction: the interface contract requires that field to be accessible, yet
15+
/// the implementation hides it.
16+
/// </summary>
17+
/// <seealso href="https://graphql.github.io/composite-schemas-spec/draft/#sec-Implemented-by-Inaccessible">
18+
/// Specification
19+
/// </seealso>
20+
internal sealed class ImplementedByInaccessibleRule
21+
: IEventHandler<ObjectTypeEvent>
22+
, IEventHandler<InterfaceTypeEvent>
23+
{
24+
public void Handle(ObjectTypeEvent @event, CompositionContext context)
25+
{
26+
var (objectType, schema) = @event;
27+
28+
Handle(objectType, schema, context);
29+
}
30+
31+
public void Handle(InterfaceTypeEvent @event, CompositionContext context)
32+
{
33+
var (interfaceType, schema) = @event;
34+
35+
Handle(interfaceType, schema, context);
36+
}
37+
38+
private static void Handle(
39+
MutableComplexTypeDefinition type,
40+
MutableSchemaDefinition schema,
41+
CompositionContext context)
42+
{
43+
var accessibleImplementedInterfaces =
44+
type.Implements
45+
.AsEnumerable()
46+
.Where(i => !i.HasFusionInaccessibleDirective());
47+
48+
foreach (var interfaceType in accessibleImplementedInterfaces)
49+
{
50+
var accessibleInterfaceFields =
51+
interfaceType.Fields.AsEnumerable().Where(f => !f.HasFusionInaccessibleDirective());
52+
53+
foreach (var interfaceField in accessibleInterfaceFields)
54+
{
55+
var field = type.Fields[interfaceField.Name];
56+
57+
if (field.HasFusionInaccessibleDirective()
58+
|| type.HasFusionInaccessibleDirective())
59+
{
60+
context.Log.Write(
61+
ImplementedByInaccessible(
62+
field,
63+
type.Name,
64+
interfaceField.Name,
65+
interfaceType.Name,
66+
schema));
67+
}
68+
}
69+
}
70+
}
71+
}

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@
189189
<data name="LogEntryHelper_FieldWithMissingRequiredArgument" xml:space="preserve">
190190
<value>The argument '{0}' must be defined as required in schema '{1}'. Arguments marked with @require are treated as non-required.</value>
191191
</data>
192+
<data name="LogEntryHelper_ImplementedByInaccessible" xml:space="preserve">
193+
<value>The field '{0}' implementing interface field '{1}' is inaccessible in the composed schema.</value>
194+
</data>
192195
<data name="LogEntryHelper_InputFieldDefaultMismatch" xml:space="preserve">
193196
<value>The default value '{0}' of input field '{1}' in schema '{2}' differs from the default value of '{3}' in schema '{4}'.</value>
194197
</data>

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public CompositionResult<MutableSchemaDefinition> Compose()
131131
new EmptyMergedObjectTypeRule(),
132132
new EmptyMergedUnionTypeRule(),
133133
new EnumTypeDefaultValueInaccessibleRule(),
134+
new ImplementedByInaccessibleRule(),
134135
new InterfaceFieldNoImplementationRule(),
135136
new IsInvalidFieldRule(),
136137
new NonNullInputFieldIsInaccessibleRule(),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
using System.Collections.Immutable;
2+
using HotChocolate.Fusion.Logging;
3+
using static HotChocolate.Fusion.CompositionTestHelper;
4+
5+
namespace HotChocolate.Fusion.PostMergeValidationRules;
6+
7+
public sealed class ImplementedByInaccessibleRuleTests
8+
{
9+
private static readonly object s_rule = new ImplementedByInaccessibleRule();
10+
private static readonly ImmutableArray<object> s_rules = [s_rule];
11+
private readonly CompositionLog _log = new();
12+
13+
[Theory]
14+
[MemberData(nameof(ValidExamplesData))]
15+
public void Examples_Valid(string[] sdl)
16+
{
17+
// arrange
18+
var schemas = CreateSchemaDefinitions(sdl);
19+
var merger = new SourceSchemaMerger(schemas);
20+
var mergeResult = merger.Merge();
21+
var validator = new PostMergeValidator(mergeResult.Value, s_rules, schemas, _log);
22+
23+
// act
24+
var result = validator.Validate();
25+
26+
// assert
27+
Assert.True(result.IsSuccess);
28+
Assert.True(_log.IsEmpty);
29+
}
30+
31+
[Theory]
32+
[MemberData(nameof(InvalidExamplesData))]
33+
public void Examples_Invalid(string[] sdl, string[] errorMessages)
34+
{
35+
// arrange
36+
var schemas = CreateSchemaDefinitions(sdl);
37+
var merger = new SourceSchemaMerger(schemas);
38+
var mergeResult = merger.Merge();
39+
var validator = new PostMergeValidator(mergeResult.Value, s_rules, schemas, _log);
40+
41+
// act
42+
var result = validator.Validate();
43+
44+
// assert
45+
Assert.True(result.IsFailure);
46+
Assert.Equal(errorMessages, _log.Select(e => e.Message).ToArray());
47+
Assert.True(_log.All(e => e.Code == "IMPLEMENTED_BY_INACCESSIBLE"));
48+
Assert.True(_log.All(e => e.Severity == LogSeverity.Error));
49+
}
50+
51+
public static TheoryData<string[]> ValidExamplesData()
52+
{
53+
return new TheoryData<string[]>
54+
{
55+
// In the following example, "User.id" is accessible and implements "Node.id" which is
56+
// also accessible, no error occurs.
57+
{
58+
[
59+
"""
60+
interface Node {
61+
id: ID!
62+
}
63+
64+
type User implements Node {
65+
id: ID!
66+
name: String
67+
}
68+
"""
69+
]
70+
},
71+
// Since "Auditable" and its field "lastAudit" are @inaccessible, the "Order.lastAudit"
72+
// field is allowed to be @inaccessible because it does not implement any visible
73+
// interface field in the composed schema.
74+
{
75+
[
76+
"""
77+
interface Auditable @inaccessible {
78+
lastAudit: DateTime!
79+
}
80+
81+
type Order implements Auditable {
82+
lastAudit: DateTime! @inaccessible
83+
orderNumber: String
84+
}
85+
"""
86+
]
87+
},
88+
// Accessible interface field "User.id" implementing accessible field "Node.id" in
89+
// another interface.
90+
{
91+
[
92+
"""
93+
interface Node {
94+
id: ID!
95+
}
96+
97+
interface User implements Node {
98+
id: ID!
99+
name: String
100+
}
101+
"""
102+
]
103+
},
104+
// Inaccessible interface field "Order.lastAudit" implementing inaccessible field
105+
// "Auditable.lastAudit" in another interface.
106+
{
107+
[
108+
"""
109+
interface Auditable @inaccessible {
110+
lastAudit: DateTime!
111+
}
112+
113+
interface Order implements Auditable {
114+
lastAudit: DateTime! @inaccessible
115+
orderNumber: String
116+
}
117+
"""
118+
]
119+
}
120+
};
121+
}
122+
123+
public static TheoryData<string[], string[]> InvalidExamplesData()
124+
{
125+
return new TheoryData<string[], string[]>
126+
{
127+
// In this example, "Node.id" is visible in the public schema (no @inaccessible), but
128+
// "User.id" is marked @inaccessible. This violates the interface contract because
129+
// "User" claims to implement "Node", yet does not expose the "id" field to the public
130+
// schema.
131+
{
132+
[
133+
"""
134+
interface Node {
135+
id: ID!
136+
}
137+
138+
type User implements Node {
139+
id: ID! @inaccessible
140+
name: String
141+
}
142+
"""
143+
],
144+
[
145+
"The field 'User.id' implementing interface field 'Node.id' is inaccessible " +
146+
"in the composed schema."
147+
]
148+
},
149+
// Same as above, for an interface type.
150+
{
151+
[
152+
"""
153+
interface Node {
154+
id: ID!
155+
}
156+
157+
interface User implements Node {
158+
id: ID! @inaccessible
159+
name: String
160+
}
161+
"""
162+
],
163+
[
164+
"The field 'User.id' implementing interface field 'Node.id' is inaccessible " +
165+
"in the composed schema."
166+
]
167+
}
168+
};
169+
}
170+
}

0 commit comments

Comments
 (0)