Skip to content

Commit 3e1a0fd

Browse files
authored
Merge pull request #571 from EntityGraphQL/feature/sdl-for-user
Add ToGraphQLSchemaString(QueryRequestContext) for user-filtered SDL
2 parents e02b00c + 8295ce1 commit 3e1a0fd

8 files changed

Lines changed: 610 additions & 67 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Changes
44

5+
- `ToGraphQLSchemaStringAsync(ClaimsPrincipal? user)` (and `ToGraphQLSchemaString(QueryRequestContext? requestContext)`) output SDL containing only what that user may access, using the schema's `AuthorizationService` as query execution does. The result is self-consistent: a type the filtering leaves empty is hidden along with the fields returning it, a field is hidden when an argument's type is hidden, hidden interfaces and union members are dropped from `implements` and union lists, and a directive with an argument of a hidden type is hidden. Useful for handing a schema to a user or AI agent (e.g. an MCP server tool) where SDL is more compact than introspection. `ToGraphQLSchemaString()` without a user still outputs the full schema. Both are default interface methods on `ISchemaProvider`, so custom implementations do not break.
6+
- Introspection for a user now hides the same things the SDL does. Previously a type with no type-level authorization but every field protected was still listed (with `fields: []`), a field taking an argument of a protected enum or input type was listed, protected interfaces still appeared in `interfaces`, and a directive with an argument of a protected type was listed. These are now hidden, so a client building a schema from introspection gets a valid one. Introspection without a user is unchanged.
57
- `[Required]` on an argument (a property/field of an arguments object or a method parameter) now behaves like `ArgumentHelper.Required<T>()` - the argument has no default value and must be provided by the query. Previously a value type kept its implicit `default(T)` as the schema default, so `[Required] int Take` printed `take: Int! = 0` (optional for clients) and a query leaving it out ran with `0`. It is now `take: Int!` and leaving it out is a `missing required argument` error. Remove `[Required]` if you relied on the old default.
68
- Introspection `__InputValue.defaultValue` no longer strips the quotes off string-like defaults. The spec defines it as the GraphQL literal, so a string default of `hello` is now returned as `"hello"` - the same text as in the SDL. Clients building a schema from introspection (`buildClientSchema`, GraphiQL, codegen against an endpoint) previously dropped such defaults or failed to parse them (`Guid`, strings with spaces). If you read `defaultValue` directly as text rather than parsing it as GraphQL, string defaults now include their quotes.
79

‎docs/docs/authorization.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,17 @@ A bare attribute with no roles or policies — `[GraphQLAuthorize]` or `[Authori
6363

6464
Introspection queries (`__schema` and `__type`) respect authorization — the result only includes the types and fields the requesting user is authorized to access, so protected type/field names are not discoverable by unauthorized users. Querying `__type(name: ...)` for a protected type returns `null`.
6565

66-
Note this applies only to introspection queries at runtime. `schema.ToGraphQLSchemaString()` always outputs the full schema — use it to generate SDL files for tooling and type generation.
66+
`schema.ToGraphQLSchemaString()` always outputs the full schema — use it to generate SDL files for tooling and type generation.
67+
68+
To get SDL filtered for a user, use `ToGraphQLSchemaStringAsync(user)`. It uses the schema's `AuthorizationService` exactly as query execution does, and applies the same rules as introspection.
69+
70+
```cs
71+
var sdl = await schema.ToGraphQLSchemaStringAsync(user);
72+
```
73+
74+
The result is self-consistent SDL: nothing in it refers to a type it does not define. Protected types are left out, as are fields that are protected, return a hidden type or take an argument of a hidden type. A type whose fields are all hidden is itself hidden, which can in turn hide the fields returning it. Protected interfaces are dropped from `implements` lists, and protected types from union member lists. A directive with an argument of a hidden type is hidden. The query root always stays: for a user who can see no query fields it is output as a bare `type Query`, which parses but fails schema validation, as there is no valid SDL for a schema with nothing in it. Introspection hides exactly the same types and fields, so `__schema`, `__type` and the SDL agree.
75+
76+
This is useful when handing a schema to a person or an AI agent (e.g. an MCP server tool), where fields they cannot query are noise or information they should not have. SDL is far more compact than an introspection result for the same information.
6777

6878
## Mutations
6979

‎src/EntityGraphQL/Schema/ISchemaProvider.cs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Security.Claims;
4+
using System.Threading;
5+
using System.Threading.Tasks;
36
using EntityGraphQL.Compiler.EntityQuery;
47
using EntityGraphQL.Directives;
58
using Microsoft.Extensions.Logging;
@@ -93,6 +96,17 @@ void AddMutationsFrom<TType>(SchemaBuilderOptions? options = null)
9396
void RemoveTypeAndAllFields<TSchemaType>();
9497
void RemoveTypeAndAllFields(string typeName);
9598
string ToGraphQLSchemaString(bool includeDescriptions = true);
99+
100+
/// <summary>SDL with only what the user in <paramref name="requestContext"/> may see. See SchemaProvider for details.</summary>
101+
// default implementations so adding these does not break anyone implementing ISchemaProvider themselves
102+
string ToGraphQLSchemaString(QueryRequestContext? requestContext, bool includeDescriptions = true) => SchemaGenerator.Make(this, includeDescriptions, requestContext);
103+
104+
/// <summary>SDL with only what <paramref name="user"/> may see, filtered by this schema's AuthorizationService. See SchemaProvider for details.</summary>
105+
async Task<string> ToGraphQLSchemaStringAsync(ClaimsPrincipal? user, bool includeDescriptions = true, CancellationToken cancellationToken = default)
106+
{
107+
var authorizationService = await AuthorizationService.PrepareForRequestAsync(this, user, cancellationToken);
108+
return SchemaGenerator.Make(this, includeDescriptions, new QueryRequestContext(authorizationService, user));
109+
}
96110
SchemaType<TType> Type<TType>();
97111
SchemaType<TType> Type<TType>(string typeName);
98112
ISchemaType Type(string typeName);

‎src/EntityGraphQL/Schema/SchemaGenerator.cs‎

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,22 @@ internal static string EscapeString(string? input)
2424
return input.Replace("\\", "\\\\").Replace("\"", "\\\"");
2525
}
2626

27-
internal static string Make(ISchemaProvider schema, bool includeDescriptions = true)
27+
/// <param name="requestContext">When supplied only the types and fields this user may access are output, using the same
28+
/// rules as introspection (see <see cref="SchemaVisibility"/>). Null outputs everything</param>
29+
internal static string Make(ISchemaProvider schema, bool includeDescriptions = true, QueryRequestContext? requestContext = null)
2830
{
31+
var visibility = SchemaVisibility.For(schema, requestContext);
2932
var rootQueryType = schema.GetSchemaType(schema.QueryContextType, false, null);
3033
var mutationType = schema.Mutation().SchemaType;
3134
var subscriptionType = schema.Subscription().SchemaType;
3235

33-
var types = BuildSchemaTypes(schema, includeDescriptions);
36+
var types = BuildSchemaTypes(schema, includeDescriptions, visibility);
3437

3538
var schemaBuilder = new StringBuilder("schema {");
3639
schemaBuilder.AppendLine();
3740
schemaBuilder.AppendLine($"\tquery: {rootQueryType.Name}");
38-
bool outputMutation = mutationType.GetFields().Any(f => !f.Name.StartsWith("__", StringComparison.InvariantCulture));
39-
bool outputSubscription = subscriptionType.GetFields().Any(f => !f.Name.StartsWith("__", StringComparison.InvariantCulture));
41+
bool outputMutation = VisibleFields(mutationType, visibility).Any();
42+
bool outputSubscription = VisibleFields(subscriptionType, visibility).Any();
4043
if (outputMutation)
4144
schemaBuilder.AppendLine($"\tmutation: {mutationType.Name}");
4245
if (outputSubscription)
@@ -45,15 +48,15 @@ internal static string Make(ISchemaProvider schema, bool includeDescriptions = t
4548

4649
schemaBuilder.AppendLine();
4750

48-
foreach (var item in schema.GetScalarTypes().Distinct().OrderBy(t => t.Name))
51+
foreach (var item in schema.GetScalarTypes().Distinct().Where(visibility.IsTypeVisible).OrderBy(t => t.Name))
4952
{
5053
if (includeDescriptions && !string.IsNullOrEmpty(item.Description))
5154
schemaBuilder.AppendLine($"\"\"\"{EscapeString(item.Description)}\"\"\"");
5255
schemaBuilder.AppendLine($"scalar {item.Name}{GetDirectives(item.Directives)}");
5356
}
5457
schemaBuilder.AppendLine();
5558

56-
foreach (var directive in schema.GetDirectives().OrderBy(t => t.Name))
59+
foreach (var directive in schema.GetDirectives().Where(visibility.IsDirectiveVisible).OrderBy(t => t.Name))
5760
{
5861
if (includeDescriptions && !string.IsNullOrEmpty(directive.Description))
5962
schemaBuilder.AppendLine($"\"\"\"{EscapeString(directive.Description)}\"\"\"");
@@ -62,27 +65,33 @@ internal static string Make(ISchemaProvider schema, bool includeDescriptions = t
6265
}
6366
schemaBuilder.AppendLine();
6467

65-
schemaBuilder.Append(BuildEnumTypes(schema, includeDescriptions));
68+
schemaBuilder.Append(BuildEnumTypes(schema, includeDescriptions, visibility));
6669

67-
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.GetSchemaType(schema.QueryContextName, null), includeDescriptions));
70+
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.GetSchemaType(schema.QueryContextName, null), includeDescriptions, visibility));
6871

6972
schemaBuilder.Append(types);
7073

7174
if (outputMutation)
72-
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.Mutation().SchemaType, includeDescriptions));
75+
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.Mutation().SchemaType, includeDescriptions, visibility));
7376
if (outputSubscription)
74-
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.Subscription().SchemaType, includeDescriptions));
77+
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.Subscription().SchemaType, includeDescriptions, visibility));
7578

7679
return schemaBuilder.ToString();
7780
}
7881

79-
private static string BuildEnumTypes(ISchemaProvider schema, bool includeDescriptions)
82+
/// <summary>Fields of a type that are output: not internal (__) and visible to the user, if filtering</summary>
83+
private static IEnumerable<IField> VisibleFields(ISchemaType schemaType, SchemaVisibility visibility) =>
84+
schemaType.GetFields().Where(f => !f.Name.StartsWith("__", StringComparison.InvariantCulture) && visibility.IsFieldVisible(f));
85+
86+
private static string BuildEnumTypes(ISchemaProvider schema, bool includeDescriptions, SchemaVisibility visibility)
8087
{
8188
var types = new StringBuilder();
8289
foreach (var typeItem in schema.GetNonContextTypes().OrderBy(t => t.Name))
8390
{
8491
if (typeItem.Name.StartsWith("__", StringComparison.InvariantCulture) || !typeItem.IsEnum)
8592
continue;
93+
if (!visibility.IsTypeVisible(typeItem))
94+
continue;
8695

8796
if (includeDescriptions && !string.IsNullOrEmpty(typeItem.Description))
8897
types.AppendLine($"\"\"\"{EscapeString(typeItem.Description)}\"\"\"");
@@ -105,7 +114,7 @@ private static string BuildEnumTypes(ISchemaProvider schema, bool includeDescrip
105114
return types.ToString();
106115
}
107116

108-
private static string BuildSchemaTypes(ISchemaProvider schema, bool includeDescriptions)
117+
private static string BuildSchemaTypes(ISchemaProvider schema, bool includeDescriptions, SchemaVisibility visibility)
109118
{
110119
var types = new StringBuilder();
111120
foreach (var typeItem in schema.GetNonContextTypes().OrderBy(t => t.Name))
@@ -119,10 +128,16 @@ private static string BuildSchemaTypes(ISchemaProvider schema, bool includeDescr
119128
)
120129
continue;
121130

131+
if (!visibility.IsTypeVisible(typeItem))
132+
continue;
133+
122134
if (!typeItem.GetFields().Any(f => !f.Name.StartsWith("__", StringComparison.InvariantCulture)) && typeItem.GqlType != GqlTypes.Union && typeItem.BaseTypesReadOnly.Count == 0)
123135
continue;
124136

125-
types.AppendLine(OutputSchemaType(schema, typeItem, includeDescriptions));
137+
var output = OutputSchemaType(schema, typeItem, includeDescriptions, visibility);
138+
// without a request context keep the historic output byte-for-byte (it appended even an empty union)
139+
if (output.Length > 0 || !visibility.IsFiltering)
140+
types.AppendLine(output);
126141
}
127142

128143
return types.ToString();
@@ -246,21 +261,23 @@ private static string GetDirectiveArgs(ISchemaProvider schema, IDirectiveProcess
246261
return string.IsNullOrEmpty(allArgs) ? string.Empty : $"({allArgs})";
247262
}
248263

249-
private static string OutputSchemaType(ISchemaProvider schema, ISchemaType schemaType, bool includeDescriptions)
264+
private static string OutputSchemaType(ISchemaProvider schema, ISchemaType schemaType, bool includeDescriptions, SchemaVisibility visibility)
250265
{
251266
var sb = new StringBuilder();
267+
var fields = VisibleFields(schemaType, visibility).OrderBy(s => s.Name).ToList();
252268

253269
if (includeDescriptions && !string.IsNullOrEmpty(schemaType.Description))
254270
sb.AppendLine($"\"\"\"{EscapeString(schemaType.Description)}\"\"\"");
255271

256272
if (schemaType.GqlType == GqlTypes.Union)
257273
{
258-
if (schemaType.PossibleTypesReadOnly.Count == 0)
274+
var possibleTypes = schemaType.PossibleTypesReadOnly.Where(visibility.IsTypeVisible).ToList();
275+
if (possibleTypes.Count == 0)
259276
{
260277
return string.Empty;
261278
}
262279

263-
sb.AppendLine($"union {schemaType.Name} = {string.Join(" | ", schemaType.PossibleTypesReadOnly.Select(i => i.Name))}");
280+
sb.AppendLine($"union {schemaType.Name} = {string.Join(" | ", possibleTypes.Select(i => i.Name))}");
264281
return sb.ToString();
265282
}
266283

@@ -273,17 +290,24 @@ private static string OutputSchemaType(ISchemaProvider schema, ISchemaType schem
273290
};
274291

275292
var implements = "";
276-
if (schemaType.BaseTypesReadOnly != null && schemaType.BaseTypesReadOnly.Count > 0)
293+
var baseTypes = schemaType.BaseTypesReadOnly?.Where(visibility.IsTypeVisible).ToList();
294+
if (baseTypes != null && baseTypes.Count > 0)
277295
{
278-
implements += $" implements {string.Join(" & ", schemaType.BaseTypesReadOnly.Select(i => i.Name))}";
296+
implements += $" implements {string.Join(" & ", baseTypes.Select(i => i.Name))}";
297+
}
298+
299+
// the query root can be left with no fields (SchemaVisibility hides any other type it empties). The SDL grammar
300+
// allows a type with no field block, so it stays a parseable root rather than disappearing from under `schema`
301+
if (visibility.IsFiltering && fields.Count == 0 && schemaType.Name == schema.QueryContextName)
302+
{
303+
sb.AppendLine($"{type} {schemaType.Name}{implements}{GetDirectives(schemaType.Directives)}");
304+
return sb.ToString();
279305
}
280306

281307
sb.AppendLine($"{type} {schemaType.Name}{implements}{GetDirectives(schemaType.Directives)} {{");
282308

283-
foreach (var field in schemaType.GetFields().OrderBy(s => s.Name))
309+
foreach (var field in fields)
284310
{
285-
if (field.Name.StartsWith("__", StringComparison.InvariantCulture))
286-
continue;
287311
if (includeDescriptions && !string.IsNullOrEmpty(field.Description))
288312
sb.AppendLine($"\t\"\"\"{EscapeString(field.Description)}\"\"\"");
289313
sb.AppendLine($"\t{schema.SchemaFieldNamer(field.Name)}{GetGqlArgs(schema, field)}: {field.ReturnType.GqlTypeForReturnOrArgument}{GetDirectives(field.DirectivesReadOnly)}");

0 commit comments

Comments
 (0)