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>
<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.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.Desktop" Version="11.3.1" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.1" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.8" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.8" />
<!--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 Include="Avalonia.Themes.Fluent" Version="11.3.1" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.8" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.3.0" />
<PackageReference Include="BlossomiShymae.Briar" Version="0.2.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.Models;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Needlework.Net
@@ -20,7 +21,7 @@ namespace Needlework.Net
_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))
{
@@ -28,14 +29,14 @@ namespace Needlework.Net
}
var lcuSchemaStream = await _githubUserContentClient.Request("/dysolix/hasagi-types/main/swagger.json")
.GetStreamAsync();
.GetStreamAsync(cancellationToken: cancellationToken);
var lcuSchemaRaw = _reader.Read(lcuSchemaStream, out var _);
var document = new Document(lcuSchemaRaw);
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))
{
@@ -43,7 +44,7 @@ namespace Needlework.Net
}
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 document = new Document(lolClientRaw);

View File

@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Media;
using Avalonia.Threading;
using BlossomiShymae.Briar;
using BlossomiShymae.Briar.Utils;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -9,6 +10,7 @@ using FluentAvalonia.UI.Controls;
using Flurl.Http;
using Flurl.Http.Configuration;
using Needlework.Net.Extensions;
using Needlework.Net.Helpers;
using Needlework.Net.Messages;
using Needlework.Net.Models;
using Needlework.Net.Services;
@@ -23,6 +25,7 @@ using System.Net.Http.Json;
using System.Reactive;
using System.Reactive.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace Needlework.Net.ViewModels.MainWindow;
@@ -104,6 +107,15 @@ public partial class MainWindowViewModel
WeakReferenceMessenger.Default.RegisterAll(this);
}
[ObservableProperty]
private bool _isPaneOpen;
[ObservableProperty]
private ObservableCollection<SchemaViewModel> _schemas = [];
[ObservableProperty]
private SchemaViewModel? _selectedSchema;
[ObservableProperty]
private ObservableCollection<NotificationViewModel> _notifications = [];
@@ -113,6 +125,9 @@ public partial class MainWindowViewModel
[ObservableProperty]
private PageBase _currentPage;
[ObservableProperty]
private SchemaSearchDetailsViewModel? _selectedSchemaSearchDetails;
public List<NavigationViewItem> NavigationViewItems { get; private set; } = [];
public bool IsSchemaVersionChecked { get; private set; }
@@ -121,6 +136,54 @@ public partial class MainWindowViewModel
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()
{
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()
{
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]
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 Microsoft.OpenApi.Models;
using Needlework.Net.Models;
using Needlework.Net.Helpers;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
namespace Needlework.Net.ViewModels.Pages.Endpoints;
@@ -15,13 +13,13 @@ public partial class OperationViewModel : ObservableObject
Summary = operation.Summary ?? string.Empty;
Description = operation.Description ?? string.Empty;
IsRequestBody = operation.RequestBody != null;
ReturnType = GetReturnType(operation.Responses);
RequestClasses = GetRequestClasses(operation.RequestBody, document);
ResponseClasses = GetResponseClasses(operation.Responses, document);
PathParameters = GetParameters(operation.Parameters.ToList(), ParameterLocation.Path);
QueryParameters = GetParameters(operation.Parameters.ToList(), ParameterLocation.Query);
RequestBodyType = GetRequestBodyType(operation.RequestBody);
RequestTemplate = GetRequestTemplate(operation.RequestBody, document);
ReturnType = OpenApiHelpers.GetReturnType(operation.Responses);
RequestClasses = OpenApiHelpers.GetRequestClasses(operation.RequestBody, document);
ResponseClasses = OpenApiHelpers.GetResponseClasses(operation.Responses, document);
PathParameters = OpenApiHelpers.GetParameters(operation.Parameters.ToList(), ParameterLocation.Path);
QueryParameters = OpenApiHelpers.GetParameters(operation.Parameters.ToList(), ParameterLocation.Query);
RequestBodyType = OpenApiHelpers.GetRequestBodyType(operation.RequestBody);
RequestTemplate = OpenApiHelpers.GetRequestTemplate(operation.RequestBody, document);
}
public string Summary { get; }
@@ -43,226 +41,4 @@ public partial class OperationViewModel : ObservableObject
public List<ParameterViewModel> QueryParameters { 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 Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Needlework.Net.Helpers;
using System.Collections.Generic;
using System.Linq;
@@ -14,7 +15,7 @@ public class PropertyClassViewModel : ObservableObject
List<PropertyEnumViewModel> propertyEnums = [];
foreach ((var propertyName, var propertySchema) in properties)
{
var type = OperationViewModel.GetSchemaType(propertySchema);
var type = OpenApiHelpers.GetSchemaType(propertySchema);
var field = new PropertyFieldViewModel(propertyName, type);
propertyFields.Add(field);
}

View File

@@ -16,7 +16,8 @@
Width="1280"
Height="720">
<Grid RowDefinitions="auto,*">
<Grid ColumnDefinitions="auto,auto,*,auto"
<Grid Name="TitleBarHost"
ColumnDefinitions="auto,auto,*,auto"
Background="Transparent"
Height="40"
Grid.Row="0">
@@ -32,7 +33,37 @@
IsHitTestVisible="False"
VerticalAlignment="Center"
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>
<SplitView Grid.Row="1"
PaneBackground="Transparent"
IsPaneOpen="{Binding IsPaneOpen}"
DisplayMode="Inline"
OpenPaneLength="350"
PanePlacement="Right">
<ui:NavigationView AlwaysShowHeader="False"
PaneDisplayMode="Left"
IsSettingsVisible="False"
@@ -84,5 +115,33 @@
</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>
</SplitView.Pane>
</SplitView>
</Grid>
</Window>

View File

@@ -1,5 +1,10 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Windowing;
using System;
namespace Needlework.Net.Views.MainWindow;
@@ -10,7 +15,35 @@ public partial class MainWindowView : AppWindow
InitializeComponent();
TitleBar.ExtendsContentIntoTitleBar = true;
TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex;
TransparencyLevelHint = [WindowTransparencyLevel.Mica, WindowTransparencyLevel.None];
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();
}
}