feat: add schema search pane

This commit is contained in:
estrogen elf
2025-06-15 14:00:58 -05:00
parent b74437d05e
commit cbc3c42116
14 changed files with 650 additions and 295 deletions

View File

@@ -0,0 +1,243 @@
using Microsoft.OpenApi.Models;
using Needlework.Net.Models;
using Needlework.Net.ViewModels.Pages.Endpoints;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
namespace Needlework.Net.Helpers
{
public static class OpenApiHelpers
{
public static string GetReturnType(OpenApiResponses responses)
{
if (!TryGetResponse(responses, out var response))
return "none";
if (response.Content.TryGetValue("application/json", out var media))
{
var schema = media.Schema;
return GetSchemaType(schema);
}
return "none";
}
public static string GetSchemaType(OpenApiSchema? schema)
{
if (schema == null) return "object"; // Because GetLolVanguardV1Notification exists where it has a required parameter without a type...
if (schema.Reference != null) return schema.Reference.Id;
if (schema.Type == "object" && schema.AdditionalProperties?.Reference != null) return schema.AdditionalProperties.Reference.Id;
if (schema.Type == "integer" || schema.Type == "number") return $"{schema.Type}:{schema.Format}";
if (schema.Type == "array" && schema.AdditionalProperties?.Reference != null) return schema.AdditionalProperties.Reference.Id;
if (schema.Type == "array" && schema.Items.Reference != null) return $"{schema.Items.Reference.Id}[]";
if (schema.Type == "array" && (schema.Items.Type == "integer" || schema.Items.Type == "number")) return $"{schema.Items.Type}:{schema.Items.Format}[]";
if (schema.Type == "array") return $"{schema.Items.Type}[]";
return schema.Type;
}
public static List<string> CreateTemplate(List<PropertyClassViewModel> requestClasses)
{
if (requestClasses.Count == 0) return [];
List<string> template = [];
template.Add("{");
var rootClass = requestClasses.First();
if (rootClass.PropertyEnums.Any()) return [rootClass.PropertyEnums.First().Values];
var propertyFields = rootClass.PropertyFields;
for (int i = 0; i < propertyFields.Count; i++)
{
template.Add($"\"{propertyFields[i].Name}\"");
template.Add(":");
template.Add($"#{propertyFields[i].Type}");
if (i == propertyFields.Count - 1) template.Add("}");
else template.Add(",");
}
for (int i = 0; i < template.Count; i++)
{
var type = template[i];
if (!type.Contains("#")) continue;
var foundClass = requestClasses.Where(c => c.Id == type.Replace("#", string.Empty));
if (foundClass.Any())
{
if (foundClass.First().PropertyEnums.Any())
{
template[i] = string.Join(string.Empty, CreateTemplate([.. foundClass]));
}
else
{
List<PropertyClassViewModel> classes = [.. requestClasses];
classes.Remove(rootClass);
template[i] = string.Join(string.Empty, CreateTemplate(classes));
}
}
else
{
template[i] = GetRequestDefaultValue(type);
}
}
return template;
}
public static string GetComponentId(OpenApiSchema schema)
{
string componentId;
if (schema.Reference != null) componentId = schema.Reference.Id;
else if (schema.Items != null) componentId = schema.Items.Reference.Id;
else componentId = schema.AdditionalProperties.Reference.Id;
return componentId;
}
public static List<ParameterViewModel> GetParameters(List<OpenApiParameter> parameters, ParameterLocation location)
{
var pathParameters = new List<ParameterViewModel>();
foreach (var parameter in parameters)
{
if (parameter.In != location) continue;
pathParameters.Add(new ParameterViewModel(parameter.Name, GetSchemaType(parameter.Schema), parameter.Required));
}
return pathParameters;
}
public static string? GetRequestBodyType(OpenApiRequestBody? requestBody)
{
if (requestBody == null) return null;
if (requestBody.Content.TryGetValue("application/json", out var media))
{
var schema = media.Schema;
if (schema == null) return null; // Because "PostLolAccountVerificationV1SendDeactivationPin" exists where the media body is empty...
return GetSchemaType(schema);
}
return null;
}
public static List<PropertyClassViewModel> GetRequestClasses(OpenApiRequestBody? requestBody, Document document)
{
if (requestBody == null) return [];
if (requestBody.Content.TryGetValue("application/json", out var media))
{
var rawDocument = document.OpenApiDocument;
var schema = media.Schema;
if (schema == null) return [];
var type = GetSchemaType(media.Schema);
if (IsComponent(type))
{
var componentId = GetComponentId(schema);
var componentSchema = rawDocument.Components.Schemas[componentId];
List<PropertyClassViewModel> propertyClasses = [];
WalkSchema(componentSchema, propertyClasses, rawDocument);
return propertyClasses;
}
}
return [];
}
public static string GetRequestDefaultValue(string type)
{
var defaultValue = string.Empty;
if (type.Contains("[]")) defaultValue = "[]";
else if (type.Contains("string")) defaultValue = "\"\"";
else if (type.Contains("boolean")) defaultValue = "false";
else if (type.Contains("integer")) defaultValue = "0";
else if (type.Contains("double") || type.Contains("float")) defaultValue = "0.0";
else if (type.Contains("object")) defaultValue = "{}";
return defaultValue;
}
public static string? GetRequestTemplate(OpenApiRequestBody? requestBody, Document document)
{
var requestClasses = GetRequestClasses(requestBody, document);
if (requestClasses.Count == 0)
{
var type = GetRequestBodyType(requestBody);
if (type == null) return null;
return GetRequestDefaultValue(type);
}
var template = CreateTemplate(requestClasses);
return JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(string.Join(string.Empty, template)), App.JsonSerializerOptions);
}
public static List<PropertyClassViewModel> GetResponseClasses(OpenApiResponses responses, Document document)
{
if (!TryGetResponse(responses, out var response))
return [];
if (response.Content.TryGetValue("application/json", out var media))
{
var rawDocument = document.OpenApiDocument;
var schema = media.Schema;
if (schema == null) return [];
List<PropertyClassViewModel> propertyClasses = [];
WalkSchema(schema, propertyClasses, rawDocument);
return propertyClasses;
}
return [];
}
public static bool IsComponent(string type)
{
return !(type.Contains("object")
|| type.Contains("array")
|| type.Contains("bool")
|| type.Contains("string")
|| type.Contains("integer")
|| type.Contains("number"));
}
public static bool TryGetResponse(OpenApiResponses responses, [NotNullWhen(true)] out OpenApiResponse? response)
{
response = null;
var flag = false;
if (responses.TryGetValue("2XX", out var x))
{
response = x;
flag = true;
}
else if (responses.TryGetValue("200", out var y))
{
response = y;
flag = true;
}
return flag;
}
public static void WalkSchema(OpenApiSchema schema, List<PropertyClassViewModel> propertyClasses, OpenApiDocument document)
{
var type = GetSchemaType(schema);
if (IsComponent(type))
{
string componentId = GetComponentId(schema);
var componentSchema = document.Components.Schemas[componentId];
var responseClass = new PropertyClassViewModel(componentId, componentSchema.Properties, componentSchema.Enum);
if (propertyClasses.Where(c => c.Id == componentId).Any()) return; // Avoid adding duplicate schemas in classes
propertyClasses.Add(responseClass);
foreach ((var _, var property) in componentSchema.Properties)
// Check for self-references like "LolLootLootOddsResponse"
// I blame dubble
if (IsComponent(GetSchemaType(property)) && componentId != GetComponentId(property))
WalkSchema(property, propertyClasses, document);
}
}
public static PropertyClassViewModel WalkSchema(OpenApiSchema schema, OpenApiDocument document)
{
string componentId = GetComponentId(schema);
var componentSchema = document.Components.Schemas[componentId];
var propertyClass = new PropertyClassViewModel(componentId, componentSchema.Properties, componentSchema.Enum);
return propertyClass;
}
}
}

View File

@@ -17,15 +17,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.1" /> <PackageReference Include="Avalonia" Version="11.2.8" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.3.0" /> <PackageReference Include="Avalonia.AvaloniaEdit" Version="11.3.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.1" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.8" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" /> <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.1" /> <PackageReference Include="Avalonia.Desktop" Version="11.2.8" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.1" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.8" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.1" /> <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.1" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.8" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.3.0" /> <PackageReference Include="AvaloniaEdit.TextMate" Version="11.3.0" />
<PackageReference Include="BlossomiShymae.Briar" Version="0.2.0" /> <PackageReference Include="BlossomiShymae.Briar" Version="0.2.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />

View File

@@ -5,6 +5,7 @@ using Microsoft.OpenApi.Readers;
using Needlework.Net.Extensions; using Needlework.Net.Extensions;
using Needlework.Net.Models; using Needlework.Net.Models;
using System; using System;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Needlework.Net namespace Needlework.Net
@@ -20,7 +21,7 @@ namespace Needlework.Net
_githubUserContentClient = clients.Get("GithubUserContentClient"); _githubUserContentClient = clients.Get("GithubUserContentClient");
} }
public async Task<Document> GetLcuSchemaDocumentAsync() public async Task<Document> GetLcuSchemaDocumentAsync(CancellationToken cancellationToken = default)
{ {
if (Cached<Document>.TryGet(nameof(GetLcuSchemaDocumentAsync), out var cached)) if (Cached<Document>.TryGet(nameof(GetLcuSchemaDocumentAsync), out var cached))
{ {
@@ -28,14 +29,14 @@ namespace Needlework.Net
} }
var lcuSchemaStream = await _githubUserContentClient.Request("/dysolix/hasagi-types/main/swagger.json") var lcuSchemaStream = await _githubUserContentClient.Request("/dysolix/hasagi-types/main/swagger.json")
.GetStreamAsync(); .GetStreamAsync(cancellationToken: cancellationToken);
var lcuSchemaRaw = _reader.Read(lcuSchemaStream, out var _); var lcuSchemaRaw = _reader.Read(lcuSchemaStream, out var _);
var document = new Document(lcuSchemaRaw); var document = new Document(lcuSchemaRaw);
return cached.Save(document, TimeSpan.FromMinutes(60)); return cached.Save(document, TimeSpan.FromMinutes(60));
} }
public async Task<Document> GetLolClientDocumentAsync() public async Task<Document> GetLolClientDocumentAsync(CancellationToken cancellationToken = default)
{ {
if (Cached<Document>.TryGet(nameof(GetLolClientDocumentAsync), out var cached)) if (Cached<Document>.TryGet(nameof(GetLolClientDocumentAsync), out var cached))
{ {
@@ -43,7 +44,7 @@ namespace Needlework.Net
} }
var lolClientStream = await _githubUserContentClient.Request("/AlsoSylv/Irelia/refs/heads/master/schemas/game_schema.json") var lolClientStream = await _githubUserContentClient.Request("/AlsoSylv/Irelia/refs/heads/master/schemas/game_schema.json")
.GetStreamAsync(); .GetStreamAsync(cancellationToken: cancellationToken);
var lolClientRaw = _reader.Read(lolClientStream, out var _); var lolClientRaw = _reader.Read(lolClientStream, out var _);
var document = new Document(lolClientRaw); var document = new Document(lolClientRaw);

View File

@@ -1,5 +1,6 @@
using Avalonia; using Avalonia;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading;
using BlossomiShymae.Briar; using BlossomiShymae.Briar;
using BlossomiShymae.Briar.Utils; using BlossomiShymae.Briar.Utils;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@@ -9,6 +10,7 @@ using FluentAvalonia.UI.Controls;
using Flurl.Http; using Flurl.Http;
using Flurl.Http.Configuration; using Flurl.Http.Configuration;
using Needlework.Net.Extensions; using Needlework.Net.Extensions;
using Needlework.Net.Helpers;
using Needlework.Net.Messages; using Needlework.Net.Messages;
using Needlework.Net.Models; using Needlework.Net.Models;
using Needlework.Net.Services; using Needlework.Net.Services;
@@ -23,6 +25,7 @@ using System.Net.Http.Json;
using System.Reactive; using System.Reactive;
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Reflection; using System.Reflection;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Needlework.Net.ViewModels.MainWindow; namespace Needlework.Net.ViewModels.MainWindow;
@@ -104,6 +107,15 @@ public partial class MainWindowViewModel
WeakReferenceMessenger.Default.RegisterAll(this); WeakReferenceMessenger.Default.RegisterAll(this);
} }
[ObservableProperty]
private bool _isPaneOpen;
[ObservableProperty]
private ObservableCollection<SchemaViewModel> _schemas = [];
[ObservableProperty]
private SchemaViewModel? _selectedSchema;
[ObservableProperty] [ObservableProperty]
private ObservableCollection<NotificationViewModel> _notifications = []; private ObservableCollection<NotificationViewModel> _notifications = [];
@@ -113,6 +125,9 @@ public partial class MainWindowViewModel
[ObservableProperty] [ObservableProperty]
private PageBase _currentPage; private PageBase _currentPage;
[ObservableProperty]
private SchemaSearchDetailsViewModel? _selectedSchemaSearchDetails;
public List<NavigationViewItem> NavigationViewItems { get; private set; } = []; public List<NavigationViewItem> NavigationViewItems { get; private set; } = [];
public bool IsSchemaVersionChecked { get; private set; } public bool IsSchemaVersionChecked { get; private set; }
@@ -121,6 +136,54 @@ public partial class MainWindowViewModel
public string Title => $"Needlework.Net {Version}"; public string Title => $"Needlework.Net {Version}";
partial void OnSelectedNavigationViewItemChanged(NavigationViewItem value)
{
if (value.Tag is PageBase page)
{
CurrentPage = page;
}
}
partial void OnSelectedSchemaSearchDetailsChanged(SchemaSearchDetailsViewModel? value)
{
if (value == null) return;
Task.Run(async () =>
{
var document = value.Tab switch
{
Pages.Endpoints.Tab.LCU => await _documentService.GetLcuSchemaDocumentAsync(),
Pages.Endpoints.Tab.GameClient => await _documentService.GetLolClientDocumentAsync(),
_ => throw new NotImplementedException()
};
var propertyClassViewModel = OpenApiHelpers.WalkSchema(document.OpenApiDocument.Components.Schemas[value.Key], document.OpenApiDocument);
var schemaViewModel = new SchemaViewModel(propertyClassViewModel);
Dispatcher.UIThread.Post(() =>
{
if (Schemas.ToList().Find(schema => schema.Id == schemaViewModel.Id) == null)
{
Schemas.Add(schemaViewModel);
IsPaneOpen = true;
OpenSchemaPaneCommand.NotifyCanExecuteChanged();
CloseSchemaAllCommand.NotifyCanExecuteChanged();
}
});
});
}
partial void OnSelectedSchemaChanged(SchemaViewModel? value)
{
CloseSchemaCommand.NotifyCanExecuteChanged();
}
partial void OnSchemasChanged(ObservableCollection<SchemaViewModel> value)
{
if (!value.Any())
{
IsPaneOpen = false;
}
}
private NavigationViewItem ToNavigationViewItem(PageBase page) => new() private NavigationViewItem ToNavigationViewItem(PageBase page) => new()
{ {
Content = page.DisplayName, Content = page.DisplayName,
@@ -140,14 +203,6 @@ public partial class MainWindowViewModel
} }
}; };
partial void OnSelectedNavigationViewItemChanged(NavigationViewItem value)
{
if (value.Tag is PageBase page)
{
CurrentPage = page;
}
}
private async Task CheckForUpdatesAsync() private async Task CheckForUpdatesAsync()
{ {
var release = await _githubClient var release = await _githubClient
@@ -194,6 +249,66 @@ public partial class MainWindowViewModel
} }
} }
public async Task<IEnumerable<object>> PopulateAsync(string? searchText, CancellationToken cancellationToken)
{
if (searchText == null) return [];
var lcuSchemaDocument = await _documentService.GetLcuSchemaDocumentAsync(cancellationToken);
var gameClientDocument = await _documentService.GetLolClientDocumentAsync(cancellationToken);
var lcuResults = lcuSchemaDocument.OpenApiDocument.Components.Schemas.Keys.Where(key => key.Contains(searchText, StringComparison.OrdinalIgnoreCase))
.Select(key => new SchemaSearchDetailsViewModel(key, Pages.Endpoints.Tab.LCU));
var gameClientResults = gameClientDocument.OpenApiDocument.Components.Schemas.Keys.Where(key => key.Contains(searchText, StringComparison.OrdinalIgnoreCase))
.Select(key => new SchemaSearchDetailsViewModel(key, Pages.Endpoints.Tab.GameClient));
return Enumerable.Concat(lcuResults, gameClientResults);
}
[RelayCommand(CanExecute = nameof(CanOpenSchemaPane))]
private void OpenSchemaPane()
{
IsPaneOpen = !IsPaneOpen;
}
private bool CanOpenSchemaPane()
{
return Schemas.Any();
}
[RelayCommand(CanExecute = nameof(CanCloseSchema))]
private void CloseSchema()
{
if (SelectedSchema is SchemaViewModel selection)
{
SelectedSchema = null;
Schemas = new ObservableCollection<SchemaViewModel>(Schemas.Where(schema => schema != selection));
OpenSchemaPaneCommand.NotifyCanExecuteChanged();
CloseSchemaCommand.NotifyCanExecuteChanged();
CloseSchemaAllCommand.NotifyCanExecuteChanged();
}
}
private bool CanCloseSchema()
{
return SelectedSchema != null;
}
[RelayCommand(CanExecute = nameof(CanCloseSchemaAll))]
private void CloseSchemaAll()
{
SelectedSchema = null;
Schemas = [];
OpenSchemaPaneCommand.NotifyCanExecuteChanged();
CloseSchemaCommand.NotifyCanExecuteChanged();
CloseSchemaAllCommand.NotifyCanExecuteChanged();
}
private bool CanCloseSchemaAll()
{
return Schemas.Any();
}
[RelayCommand] [RelayCommand]
private void OpenUrl(string url) private void OpenUrl(string url)
{ {

View File

@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Needlework.Net.ViewModels.Pages.Endpoints;
namespace Needlework.Net.ViewModels.MainWindow
{
public partial class SchemaSearchDetailsViewModel : ObservableObject
{
public SchemaSearchDetailsViewModel(string key, Tab tab)
{
Tab = tab;
Key = key;
}
public string Key { get; }
public Tab Tab { get; }
public string Document => Tab switch
{
Tab.LCU => "LCU",
Tab.GameClient => "Game Client",
_ => throw new System.NotImplementedException()
};
}
}

View File

@@ -0,0 +1,22 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Needlework.Net.ViewModels.Pages.Endpoints;
using System.Collections.Generic;
namespace Needlework.Net.ViewModels.MainWindow
{
public partial class SchemaViewModel : ObservableObject
{
public SchemaViewModel(PropertyClassViewModel vm)
{
Id = vm.Id;
PropertyFields = vm.PropertyFields;
PropertyEnums = vm.PropertyEnums;
}
public string Id { get; }
public List<PropertyFieldViewModel> PropertyFields { get; } = [];
public List<PropertyEnumViewModel> PropertyEnums { get; } = [];
}
}

View File

@@ -1,10 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Needlework.Net.Models; using Needlework.Net.Helpers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Text.Json;
namespace Needlework.Net.ViewModels.Pages.Endpoints; namespace Needlework.Net.ViewModels.Pages.Endpoints;
@@ -15,13 +13,13 @@ public partial class OperationViewModel : ObservableObject
Summary = operation.Summary ?? string.Empty; Summary = operation.Summary ?? string.Empty;
Description = operation.Description ?? string.Empty; Description = operation.Description ?? string.Empty;
IsRequestBody = operation.RequestBody != null; IsRequestBody = operation.RequestBody != null;
ReturnType = GetReturnType(operation.Responses); ReturnType = OpenApiHelpers.GetReturnType(operation.Responses);
RequestClasses = GetRequestClasses(operation.RequestBody, document); RequestClasses = OpenApiHelpers.GetRequestClasses(operation.RequestBody, document);
ResponseClasses = GetResponseClasses(operation.Responses, document); ResponseClasses = OpenApiHelpers.GetResponseClasses(operation.Responses, document);
PathParameters = GetParameters(operation.Parameters.ToList(), ParameterLocation.Path); PathParameters = OpenApiHelpers.GetParameters(operation.Parameters.ToList(), ParameterLocation.Path);
QueryParameters = GetParameters(operation.Parameters.ToList(), ParameterLocation.Query); QueryParameters = OpenApiHelpers.GetParameters(operation.Parameters.ToList(), ParameterLocation.Query);
RequestBodyType = GetRequestBodyType(operation.RequestBody); RequestBodyType = OpenApiHelpers.GetRequestBodyType(operation.RequestBody);
RequestTemplate = GetRequestTemplate(operation.RequestBody, document); RequestTemplate = OpenApiHelpers.GetRequestTemplate(operation.RequestBody, document);
} }
public string Summary { get; } public string Summary { get; }
@@ -43,226 +41,4 @@ public partial class OperationViewModel : ObservableObject
public List<ParameterViewModel> QueryParameters { get; } public List<ParameterViewModel> QueryParameters { get; }
public string? RequestTemplate { get; } public string? RequestTemplate { get; }
private string? GetRequestTemplate(OpenApiRequestBody? requestBody, Document document)
{
var requestClasses = GetRequestClasses(requestBody, document);
if (requestClasses.Count == 0)
{
var type = GetRequestBodyType(requestBody);
if (type == null) return null;
return GetRequestDefaultValue(type);
}
var template = CreateTemplate(requestClasses);
return JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(string.Join(string.Empty, template)), App.JsonSerializerOptions);
}
private List<string> CreateTemplate(List<PropertyClassViewModel> requestClasses)
{
if (requestClasses.Count == 0) return [];
List<string> template = [];
template.Add("{");
var rootClass = requestClasses.First();
if (rootClass.PropertyEnums.Any()) return [rootClass.PropertyEnums.First().Values];
var propertyFields = rootClass.PropertyFields;
for (int i = 0; i < propertyFields.Count; i++)
{
template.Add($"\"{propertyFields[i].Name}\"");
template.Add(":");
template.Add($"#{propertyFields[i].Type}");
if (i == propertyFields.Count - 1) template.Add("}");
else template.Add(",");
}
for (int i = 0; i < template.Count; i++)
{
var type = template[i];
if (!type.Contains("#")) continue;
var foundClass = requestClasses.Where(c => c.Id == type.Replace("#", string.Empty));
if (foundClass.Any())
{
if (foundClass.First().PropertyEnums.Any())
{
template[i] = string.Join(string.Empty, CreateTemplate([.. foundClass]));
}
else
{
List<PropertyClassViewModel> classes = [.. requestClasses];
classes.Remove(rootClass);
template[i] = string.Join(string.Empty, CreateTemplate(classes));
}
}
else
{
template[i] = GetRequestDefaultValue(type);
}
}
return template;
}
private static string GetRequestDefaultValue(string type)
{
var defaultValue = string.Empty;
if (type.Contains("[]")) defaultValue = "[]";
else if (type.Contains("string")) defaultValue = "\"\"";
else if (type.Contains("boolean")) defaultValue = "false";
else if (type.Contains("integer")) defaultValue = "0";
else if (type.Contains("double") || type.Contains("float")) defaultValue = "0.0";
else if (type.Contains("object")) defaultValue = "{}";
return defaultValue;
}
private string? GetRequestBodyType(OpenApiRequestBody? requestBody)
{
if (requestBody == null) return null;
if (requestBody.Content.TryGetValue("application/json", out var media))
{
var schema = media.Schema;
if (schema == null) return null; // Because "PostLolAccountVerificationV1SendDeactivationPin" exists where the media body is empty...
return GetSchemaType(schema);
}
return null;
}
private List<ParameterViewModel> GetParameters(List<OpenApiParameter> parameters, ParameterLocation location)
{
var pathParameters = new List<ParameterViewModel>();
foreach (var parameter in parameters)
{
if (parameter.In != location) continue;
pathParameters.Add(new ParameterViewModel(parameter.Name, GetSchemaType(parameter.Schema), parameter.Required));
}
return pathParameters;
}
private bool TryGetResponse(OpenApiResponses responses, [NotNullWhen(true)] out OpenApiResponse? response)
{
response = null;
var flag = false;
if (responses.TryGetValue("2XX", out var x))
{
response = x;
flag = true;
}
else if (responses.TryGetValue("200", out var y))
{
response = y;
flag = true;
}
return flag;
}
private List<PropertyClassViewModel> GetResponseClasses(OpenApiResponses responses, Document document)
{
if (!TryGetResponse(responses, out var response))
return [];
if (response.Content.TryGetValue("application/json", out var media))
{
var rawDocument = document.OpenApiDocument;
var schema = media.Schema;
if (schema == null) return [];
List<PropertyClassViewModel> propertyClasses = [];
WalkSchema(schema, propertyClasses, rawDocument);
return propertyClasses;
}
return [];
}
private void WalkSchema(OpenApiSchema schema, List<PropertyClassViewModel> propertyClasses, OpenApiDocument document)
{
var type = GetSchemaType(schema);
if (IsComponent(type))
{
string componentId = GetComponentId(schema);
var componentSchema = document.Components.Schemas[componentId];
var responseClass = new PropertyClassViewModel(componentId, componentSchema.Properties, componentSchema.Enum);
if (propertyClasses.Where(c => c.Id == componentId).Any()) return; // Avoid adding duplicate schemas in classes
propertyClasses.Add(responseClass);
foreach ((var _, var property) in componentSchema.Properties)
// Check for self-references like "LolLootLootOddsResponse"
// I blame dubble
if (IsComponent(GetSchemaType(property)) && componentId != GetComponentId(property))
WalkSchema(property, propertyClasses, document);
}
}
private static string GetComponentId(OpenApiSchema schema)
{
string componentId;
if (schema.Reference != null) componentId = schema.Reference.Id;
else if (schema.Items != null) componentId = schema.Items.Reference.Id;
else componentId = schema.AdditionalProperties.Reference.Id;
return componentId;
}
private static bool IsComponent(string type)
{
return !(type.Contains("object")
|| type.Contains("array")
|| type.Contains("bool")
|| type.Contains("string")
|| type.Contains("integer")
|| type.Contains("number"));
}
private List<PropertyClassViewModel> GetRequestClasses(OpenApiRequestBody? requestBody, Document document)
{
if (requestBody == null) return [];
if (requestBody.Content.TryGetValue("application/json", out var media))
{
var rawDocument = document.OpenApiDocument;
var schema = media.Schema;
if (schema == null) return [];
var type = GetSchemaType(media.Schema);
if (IsComponent(type))
{
var componentId = GetComponentId(schema);
var componentSchema = rawDocument.Components.Schemas[componentId];
List<PropertyClassViewModel> propertyClasses = [];
WalkSchema(componentSchema, propertyClasses, rawDocument);
return propertyClasses;
}
}
return [];
}
private string GetReturnType(OpenApiResponses responses)
{
if (!TryGetResponse(responses, out var response))
return "none";
if (response.Content.TryGetValue("application/json", out var media))
{
var schema = media.Schema;
return GetSchemaType(schema);
}
return "none";
}
public static string GetSchemaType(OpenApiSchema? schema)
{
if (schema == null) return "object"; // Because GetLolVanguardV1Notification exists where it has a required parameter without a type...
if (schema.Reference != null) return schema.Reference.Id;
if (schema.Type == "object" && schema.AdditionalProperties?.Reference != null) return schema.AdditionalProperties.Reference.Id;
if (schema.Type == "integer" || schema.Type == "number") return $"{schema.Type}:{schema.Format}";
if (schema.Type == "array" && schema.AdditionalProperties?.Reference != null) return schema.AdditionalProperties.Reference.Id;
if (schema.Type == "array" && schema.Items.Reference != null) return $"{schema.Items.Reference.Id}[]";
if (schema.Type == "array" && (schema.Items.Type == "integer" || schema.Items.Type == "number")) return $"{schema.Items.Type}:{schema.Items.Format}[]";
if (schema.Type == "array") return $"{schema.Items.Type}[]";
return schema.Type;
}
} }

View File

@@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Needlework.Net.Helpers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -14,7 +15,7 @@ public class PropertyClassViewModel : ObservableObject
List<PropertyEnumViewModel> propertyEnums = []; List<PropertyEnumViewModel> propertyEnums = [];
foreach ((var propertyName, var propertySchema) in properties) foreach ((var propertyName, var propertySchema) in properties)
{ {
var type = OperationViewModel.GetSchemaType(propertySchema); var type = OpenApiHelpers.GetSchemaType(propertySchema);
var field = new PropertyFieldViewModel(propertyName, type); var field = new PropertyFieldViewModel(propertyName, type);
propertyFields.Add(field); propertyFields.Add(field);
} }

View File

@@ -16,7 +16,8 @@
Width="1280" Width="1280"
Height="720"> Height="720">
<Grid RowDefinitions="auto,*"> <Grid RowDefinitions="auto,*">
<Grid ColumnDefinitions="auto,auto,*,auto" <Grid Name="TitleBarHost"
ColumnDefinitions="auto,auto,*,auto"
Background="Transparent" Background="Transparent"
Height="40" Height="40"
Grid.Row="0"> Grid.Row="0">
@@ -32,8 +33,38 @@
IsHitTestVisible="False" IsHitTestVisible="False"
VerticalAlignment="Center" VerticalAlignment="Center"
Grid.Column="1"/> Grid.Column="1"/>
<Border Grid.Column="2" Padding="4" IsHitTestVisible="True">
<StackPanel HorizontalAlignment="Center"
Orientation="Horizontal">
<AutoCompleteBox Name="SchemaAutoCompleteBox"
Margin="0 0 8 0"
MinWidth="200"
MaxWidth="500"
MaxDropDownHeight="400"
Watermark="Search schemas"
FilterMode="None"
ValueMemberBinding="{Binding Key, DataType=vm:SchemaSearchDetailsViewModel}"
AsyncPopulator="{Binding PopulateAsync}"
SelectedItem="{Binding SelectedSchemaSearchDetails}">
<AutoCompleteBox.ItemTemplate>
<DataTemplate x:DataType="vm:SchemaSearchDetailsViewModel">
<ContentControl Content="{Binding}"/>
</DataTemplate>
</AutoCompleteBox.ItemTemplate>
</AutoCompleteBox>
<Button i:Attached.Icon="fa-solid fa-file-lines"
FontSize="20"
Command="{Binding OpenSchemaPaneCommand}"/>
</StackPanel>
</Border>
</Grid> </Grid>
<ui:NavigationView AlwaysShowHeader="False" <SplitView Grid.Row="1"
PaneBackground="Transparent"
IsPaneOpen="{Binding IsPaneOpen}"
DisplayMode="Inline"
OpenPaneLength="350"
PanePlacement="Right">
<ui:NavigationView AlwaysShowHeader="False"
PaneDisplayMode="Left" PaneDisplayMode="Left"
IsSettingsVisible="False" IsSettingsVisible="False"
IsPaneOpen="False" IsPaneOpen="False"
@@ -41,48 +72,76 @@
Grid.Row="1" Grid.Row="1"
MenuItemsSource="{Binding NavigationViewItems}" MenuItemsSource="{Binding NavigationViewItems}"
SelectedItem="{Binding SelectedNavigationViewItem}"> SelectedItem="{Binding SelectedNavigationViewItem}">
<ui:NavigationView.PaneFooter> <ui:NavigationView.PaneFooter>
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<StackPanel.Styles> <StackPanel.Styles>
<Style Selector="materialIcons|MaterialIcon"> <Style Selector="materialIcons|MaterialIcon">
<Setter Property="Width" Value="20" /> <Setter Property="Width" Value="20" />
<Setter Property="Height" Value="20" /> <Setter Property="Height" Value="20" />
</Style> </Style>
<Style Selector="i|Icon"> <Style Selector="i|Icon">
<Setter Property="FontSize" Value="20" /> <Setter Property="FontSize" Value="20" />
</Style> </Style>
</StackPanel.Styles> </StackPanel.Styles>
<Button <Button
Theme="{StaticResource TransparentButton}" Theme="{StaticResource TransparentButton}"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding OpenUrlCommand}" Command="{Binding OpenUrlCommand}"
CommandParameter="https://github.com/BlossomiShymae/Needlework.Net" CommandParameter="https://github.com/BlossomiShymae/Needlework.Net"
ToolTip.Tip="Open on GitHub." ToolTip.Tip="Open on GitHub."
Margin="4"> Margin="4">
<materialIcons:MaterialIcon Kind="Github" /> <materialIcons:MaterialIcon Kind="Github" />
</Button> </Button>
<Button <Button
Theme="{StaticResource TransparentButton}" Theme="{StaticResource TransparentButton}"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding OpenUrlCommand}" Command="{Binding OpenUrlCommand}"
CommandParameter="https://discord.gg/chEvEX5J4E" CommandParameter="https://discord.gg/chEvEX5J4E"
ToolTip.Tip="Open Discord server." ToolTip.Tip="Open Discord server."
Margin="4"> Margin="4">
<i:Icon Value="fa-brand fa-discord" /> <i:Icon Value="fa-brand fa-discord" />
</Button> </Button>
</StackPanel>
</ui:NavigationView.PaneFooter>
<Grid>
<TransitioningContentControl Content="{Binding CurrentPage}"/>
<ItemsControl ItemsSource="{Binding Notifications}"
VerticalAlignment="Bottom">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:NotificationViewModel">
<ContentControl Content="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</ui:NavigationView>
<SplitView.Pane>
<StackPanel>
<ui:CommandBar DefaultLabelPosition="Right">
<ui:CommandBar.PrimaryCommands>
<ui:CommandBarButton Name="CloseCommandBarButton" Label="Close" Command="{Binding CloseSchemaCommand}"/>
<ui:CommandBarButton Name="CloseAllCommandBarButton" Label="Close all" Command="{Binding CloseSchemaAllCommand}"/>
</ui:CommandBar.PrimaryCommands>
</ui:CommandBar>
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
Margin="8 0 8 0">
<ListBox ItemsSource="{Binding Schemas}" SelectedItem="{Binding SelectedSchema}">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"></Setter>
<Setter Property="Padding" Value="0"></Setter>
<Setter Property="Margin" Value="0 0 0 8"></Setter>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate>
<ContentControl Content="{Binding}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</StackPanel> </StackPanel>
</ui:NavigationView.PaneFooter> </SplitView.Pane>
<Grid> </SplitView>
<TransitioningContentControl Content="{Binding CurrentPage}"/>
<ItemsControl ItemsSource="{Binding Notifications}"
VerticalAlignment="Bottom">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:NotificationViewModel">
<ContentControl Content="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</ui:NavigationView>
</Grid> </Grid>
</Window> </Window>

View File

@@ -1,5 +1,10 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Windowing; using FluentAvalonia.UI.Windowing;
using System;
namespace Needlework.Net.Views.MainWindow; namespace Needlework.Net.Views.MainWindow;
@@ -10,7 +15,35 @@ public partial class MainWindowView : AppWindow
InitializeComponent(); InitializeComponent();
TitleBar.ExtendsContentIntoTitleBar = true; TitleBar.ExtendsContentIntoTitleBar = true;
TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex;
TransparencyLevelHint = [WindowTransparencyLevel.Mica, WindowTransparencyLevel.None]; TransparencyLevelHint = [WindowTransparencyLevel.Mica, WindowTransparencyLevel.None];
Background = IsWindows11 ? null : Background; Background = IsWindows11 ? null : Background;
SchemaAutoCompleteBox.MinimumPopulateDelay = TimeSpan.FromSeconds(1);
SchemaAutoCompleteBox.MinimumPrefixLength = 3;
CloseCommandBarButton.IconSource = new ImageIconSource
{
Source = new Projektanker.Icons.Avalonia.IconImage()
{
Value = "fa-solid fa-file-circle-xmark",
Brush = new SolidColorBrush(Application.Current!.ActualThemeVariant.Key switch
{
"Light" => Colors.Black,
"Dark" => Colors.White,
_ => Colors.Gray
})
}
};
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
if (VisualRoot is AppWindow aw)
{
TitleBarHost.ColumnDefinitions[3].Width = new GridLength(aw.TitleBar.RightInset, GridUnitType.Pixel);
}
} }
} }

View File

@@ -0,0 +1,16 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Needlework.Net.ViewModels.MainWindow"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Needlework.Net.Views.MainWindow.SchemaSearchDetailsView"
x:DataType="vm:SchemaSearchDetailsViewModel">
<StackPanel HorizontalAlignment="Left"
VerticalAlignment="Center">
<TextBlock Text="{Binding Key}"/>
<TextBlock Text="{Binding Document}"
Theme="{StaticResource CaptionTextBlockStyle}"
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"/>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Needlework.Net.Views.MainWindow;
public partial class SchemaSearchDetailsView : UserControl
{
public SchemaSearchDetailsView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,42 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Needlework.Net.ViewModels.MainWindow"
xmlns:controls="using:Needlework.Net.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Needlework.Net.Views.MainWindow.SchemaView"
x:DataType="vm:SchemaViewModel">
<UserControl.Styles>
<Style Selector="DataGrid">
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource ControlElevationBorderBrush}"/>
</Style>
<Style Selector="DataGridColumnHeader TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}"/>
</Style>
<Style Selector="DataGridRow DataGridCell">
<Setter Property="FontSize" Value="12"></Setter>
</Style>
<Style Selector="DataGridRow">
<Setter Property="Margin" Value="0 0 0 4"></Setter>
</Style>
</UserControl.Styles>
<controls:Card>
<StackPanel>
<TextBlock FontSize="12" FontWeight="DemiBold" Text="{Binding Id}" Margin="0 0 0 4"/>
<DataGrid IsVisible="{Binding PropertyFields, Converter={StaticResource EnumerableToVisibilityConverter}}"
ItemsSource="{Binding PropertyFields}"
AutoGenerateColumns="True"
IsReadOnly="True"
GridLinesVisibility="Horizontal">
</DataGrid>
<DataGrid IsVisible="{Binding PropertyEnums, Converter={StaticResource EnumerableToVisibilityConverter}}"
Margin="0 0 0 8"
ItemsSource="{Binding PropertyEnums}"
AutoGenerateColumns="True"
IsReadOnly="True"
GridLinesVisibility="Horizontal">
</DataGrid>
</StackPanel>
</controls:Card>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Needlework.Net.Views.MainWindow;
public partial class SchemaView : UserControl
{
public SchemaView()
{
InitializeComponent();
}
}