Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ recognise. `scalarMappings` lets you override both halves of that:
* Change an existing mapping the same way. `"DateTime": "System.DateTime"` emits `DateTime` instead
of the default `DateTimeOffset`.
* Map a scalar to **null** to opt it out of the built-in mapping, so a `CustomScalar` class is
generated for it instead and you control the conversion yourself.
generated for it instead and you control the conversion yourself. GraphQL's own `Int`, `Float`,
`String` and `Boolean` can be mapped to another type but not opted out.

Scalar names are matched case insensitively. The target must be one of the supported simple types -
`bool`, `byte`, `sbyte`, `char`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`, `float`,
Expand Down
34 changes: 29 additions & 5 deletions docs/Linq2GraphQL.Docs/Components/GenerateClient.razor
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
<Checkbox Switch @bind-Value="options.IncludeDeprecated"/>
</DataGridItem>

<DataGridItem Title="Description attributes">
<Checkbox Switch @bind-Value="options.DescriptionAttributes"/>
</DataGridItem>

<DataGridItem Title="Standalone">
<Checkbox Switch @bind-Value="options.Standalone"/>
</DataGridItem>
Expand All @@ -62,8 +66,10 @@
</DataGrid>


<Button class="mt-3" BackgroundColor="TablerColor.Primary" OnClick="GenerateClientAsync">Generate
</Button>
<div class="mt-3 d-flex gap-2">
<Button OnClick="LoadFromUrlAsync">Load schema</Button>
<Button BackgroundColor="TablerColor.Primary" OnClick="GenerateClientAsync">Generate</Button>
</div>

</Tab>

Expand All @@ -81,13 +87,31 @@
<InputTextArea class="form-control font-monospace" @bind-Value="options.Schema" rows="20"
placeholder="type Query { ... }"/>

<Button class="mt-3" BackgroundColor="TablerColor.Primary" OnClick="GenerateClientFromSchema">
Generate @(IsIntrospectionJson(options.Schema) ? "from introspection JSON" : "from SDL")
</Button>
<div class="mt-3 d-flex gap-2">
<Button OnClick="LoadFromSchemaAsync">Load schema</Button>
<Button BackgroundColor="TablerColor.Primary" OnClick="GenerateClientFromSchema">
Generate @(IsIntrospectionJson(options.Schema) ? "from introspection JSON" : "from SDL")
</Button>
</div>
</Tab>

</Tabs>

<div class="mt-3 text-secondary">
@if (IsLoaded)
{
<span>
@scalars.Count scalars: @CustomScalars.Count() generate a CustomScalar class,
@OverrideCount changed from the default.
</span>
<Button class="ms-2" Size="ButtonSize.Small" OnClick="ReviewScalarsAsync">Review scalar mappings</Button>
}
else
{
<span>Load the schema to review how its scalars map to C# types before generating.</span>
}
</div>


</CardBody>
</Card>
Expand Down
152 changes: 125 additions & 27 deletions docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using Linq2GraphQL.Generator;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
Expand Down Expand Up @@ -123,51 +125,140 @@ private async Task LoadSchemaFileAsync(InputFileChangeEventArgs e)
/// </summary>
private static bool IsIntrospectionJson(string schema) => schema?.TrimStart().StartsWith('{') == true;

private async Task GenerateClientFromSchema()
/// <summary>
/// Where a schema was read from. A schema is reused only while all of these are unchanged; deprecated fields
/// are part of what is read, so toggling them needs a new read too.
/// </summary>
private record SchemaSource(string Url, string Token, string Schema, bool IncludeDeprecated);

private RootSchema loadedSchema;
private SchemaSource loadedFrom;
private List<ScalarInfo> scalars = new();

private SchemaSource UrlSource => new(options.Url, options.Token, null, options.IncludeDeprecated);
private SchemaSource TextSource => new(null, null, options.Schema, options.IncludeDeprecated);

private bool IsLoaded => loadedSchema != null && (loadedFrom == UrlSource || loadedFrom == TextSource);

private IEnumerable<ScalarInfo> CustomScalars => scalars.Where(e =>
(options.ScalarOverrides.TryGetValue(e.Name, out var target) ? target : e.DefaultTarget ?? "") == "");

private int OverrideCount => scalars.Count(e => options.ScalarOverrides.ContainsKey(e.Name));

private Task LoadFromUrlAsync() => RunAsync(() => EnsureLoadedAsync(UrlSource));

private Task LoadFromSchemaAsync() => RunAsync(() => EnsureLoadedAsync(TextSource));

private Task GenerateClientAsync() => GenerateAsync(UrlSource);

private Task GenerateClientFromSchema() => GenerateAsync(TextSource);

private Task GenerateAsync(SchemaSource source) => RunAsync(async () =>
{
try
await EnsureLoadedAsync(source);
await SaveEntriesAsync(CreateGenerator().Generate(loadedSchema));
});

private async Task EnsureLoadedAsync(SchemaSource source)
{
if (loadedSchema != null && loadedFrom == source)
{
if (string.IsNullOrWhiteSpace(options.Schema))
{
throw new InvalidOperationException("Upload or paste a schema first.");
}
return;
}

isLoading = true;
var generator = new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions,
options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated,
agent: options.Agent, standalone: options.Standalone);
var entries = IsIntrospectionJson(options.Schema)
? generator.Generate(options.Schema)
: generator.GenerateFromSdl(options.Schema);
await SaveEntriesAsync(entries);
var reader = CreateGenerator();
RootSchema schema;

if (source.Url != null)
{
schema = await reader.ReadSchemaAsync(new(source.Url), source.Token);
}
catch (Exception ex)
else if (string.IsNullOrWhiteSpace(source.Schema))
{
var component = new RenderComponent<ExceptionModal>().Set(e => e.Exception, ex);
var result = await modalService.ShowAsync("Error", component, new() { Size = ModalSize.Large });
throw new InvalidOperationException("Upload or paste a schema first.");
}
finally
else
{
isLoading = false;
schema = IsIntrospectionJson(source.Schema)
? reader.ReadSchema(source.Schema)
: reader.ReadSchemaFromSdl(source.Schema);
}

loadedSchema = schema;
loadedFrom = source;
scalars = schema.Data.Schema.GetScalars();
}

private async Task ReviewScalarsAsync()
{
var component = new RenderComponent<ScalarMappingsModal>()
.Set(e => e.Scalars, scalars)
.Set(e => e.Overrides, options.ScalarOverrides)
.Set(e => e.ConfigJson, ConfigJson);
await modalService.ShowAsync("Scalar mappings", component, new() { Size = ModalSize.Large });
}

private async Task GenerateClientAsync()
private ClientGenerator CreateGenerator()
{
return new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions,
options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated,
Helpers.ResolveScalarMappings(ScalarMappings()), options.DescriptionAttributes, options.Agent,
options.Standalone);
}

/// <summary>
/// The overrides for the scalars of the loaded schema, as the config file's scalarMappings: an empty target
/// becomes null, "generate a CustomScalar class". Overrides kept from another schema are left out.
/// </summary>
private Dictionary<string, string> ScalarMappings()
{
return scalars.Where(e => options.ScalarOverrides.ContainsKey(e.Name))
.ToDictionary(e => e.Name, e => options.ScalarOverrides[e.Name] is "" ? null : options.ScalarOverrides[e.Name]);
}

/// <summary>
/// The page's settings as a linq2graphql.json file for the command line, leaving out settings that have their
/// default value. The token is never included.
/// </summary>
private string ConfigJson()
{
var mappings = ScalarMappings();
var config = new GeneratorConfig
{
Endpoint = loadedFrom?.Url,
Namespace = options.Namespace,
Client = options.ClientName,
Subscriptions = options.IncludeSubscriptions ? true : null,
EnumStrategy = options.EnumGeneratorStrategy == EnumGeneratorStrategy.FailIfMissing
? null
: options.EnumGeneratorStrategy.ToString(),
Nullable = options.Nullable ? true : null,
Deprecated = options.IncludeDeprecated ? true : null,
Agent = options.Agent ? true : null,
Standalone = options.Standalone ? true : null,
DescriptionAttributes = options.DescriptionAttributes ? null : false,
ScalarMappings = mappings.Count > 0 ? mappings : null
};

return JsonSerializer.Serialize(config, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
});
}

private async Task RunAsync(Func<Task> action)
{
try
{
isLoading = true;
var generator = new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions,
options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated,
agent: options.Agent, standalone: options.Standalone);
var entries = await generator.GenerateAsync(new(options.Url), options.Token);
await SaveEntriesAsync(entries);
await action();
}
catch (Exception ex)
{
var component = new RenderComponent<ExceptionModal>().Set(e => e.Exception, ex);
var result = await modalService.ShowAsync("Error", component, new() { Size = ModalSize.Large });
await modalService.ShowAsync("Error", component, new() { Size = ModalSize.Large });
}
finally
{
Expand All @@ -187,7 +278,14 @@ public class GenerateOptions
public bool IncludeDeprecated { get; set; }
public bool Standalone { get; set; }
public bool Agent { get; set; }
public bool DescriptionAttributes { get; set; } = true;
public EnumGeneratorStrategy EnumGeneratorStrategy { get; set; }

public string Schema { get; set; }
}

/// <summary>
/// Scalar name to C# type for the scalars whose mapping was changed in the review; an empty type means
/// "generate a CustomScalar class". Kept per example, so a reloaded schema keeps its changes.
/// </summary>
public Dictionary<string, string> ScalarOverrides { get; set; } = new(StringComparer.InvariantCultureIgnoreCase);
}
111 changes: 111 additions & 0 deletions docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
@using Linq2GraphQL.Generator
@using TabBlazor.Services

<p class="text-secondary">
Every scalar in the schema and the C# type it becomes. Change a row to override the default, or choose
<em>CustomScalar class</em> to generate a class for the scalar instead. GraphQL's own scalars can be mapped
to another type but not to a class.
</p>

<div class="table-responsive">
<table class="table table-vcenter card-table border">
<thead>
<tr><th>Scalar</th><th>C# type</th><th></th></tr>
</thead>
<tbody>
@foreach (var scalar in Scalars.OrderBy(e => e.IsBuiltIn))
{
<tr>
<td>
<code>@scalar.Name</code>
@if (scalar.IsBuiltIn)
{
<span class="badge bg-secondary-lt ms-1">GraphQL built-in</span>
}
@if (!string.IsNullOrWhiteSpace(scalar.Description))
{
<div class="text-secondary small">@scalar.Description</div>
}
</td>
<td>
<select class="form-select form-select-sm" value="@Target(scalar)"
@onchange="e => SetTarget(scalar, (string)e.Value)">
@if (!scalar.IsBuiltIn)
{
<option value="">CustomScalar class</option>
}
@foreach (var target in Helpers.SupportedTargetTypeNames)
{
<option value="@target">@target</option>
}
</select>
</td>
<td class="text-nowrap">
@if (Overrides.ContainsKey(scalar.Name))
{
<span class="badge bg-blue-lt">changed</span>
<a href="" class="ms-2" @onclick:preventDefault @onclick="() => Overrides.Remove(scalar.Name)">
reset
</a>
}
else
{
<span class="text-secondary">default</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>

<div class="d-flex gap-2 mt-3">
<Button OnClick="CopyConfigAsync">Copy as linq2graphql.json</Button>
<Button OnClick="() => Overrides.Clear()" Disabled="Overrides.Count == 0">Reset all</Button>
<Button class="ms-auto" BackgroundColor="TablerColor.Primary" OnClick="() => modalService.Close()">Done</Button>
</div>

@code {
[Parameter] public List<ScalarInfo> Scalars { get; set; }

/// <summary>
/// Scalar name to C# type, edited in place. An empty type means "generate a CustomScalar class", like null
/// in the config file. Only scalars that differ from their default are in here.
/// </summary>
[Parameter] public Dictionary<string, string> Overrides { get; set; }

/// <summary>
/// The page's settings, these overrides included, as the contents of a linq2graphql.json file.
/// </summary>
[Parameter] public Func<string> ConfigJson { get; set; }

[Inject] private IModalService modalService { get; set; }
[Inject] private TablerService tablerService { get; set; }
[Inject] private ToastService toastService { get; set; }

private string Target(ScalarInfo scalar)
{
return Overrides.TryGetValue(scalar.Name, out var target) ? target : scalar.DefaultTarget ?? "";
}

private void SetTarget(ScalarInfo scalar, string target)
{
if (target == (scalar.DefaultTarget ?? ""))
{
Overrides.Remove(scalar.Name);
}
else
{
Overrides[scalar.Name] = target;
}
}

private async Task CopyConfigAsync()
{
await tablerService.CopyToClipboard(ConfigJson());
await toastService.AddToastAsync(new()
{
Title = "Copy Complete", Message = "The settings have been copied to the clipboard as linq2graphql.json"
});
}
}
2 changes: 2 additions & 0 deletions docs/Linq2GraphQL.Docs/Pages/Generator.razor
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@
<li>
Map a scalar to <strong>null</strong> to opt it out of the built-in mapping, so a
<code>CustomScalar</code> class is generated for it and you control the conversion yourself.
GraphQL's own <code>Int</code>, <code>Float</code>, <code>String</code> and <code>Boolean</code> can be
mapped to another type but not opted out.
</li>
</ul>
<p>
Expand Down
Loading
Loading