diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f9bdcf4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.{cs,vb}] + +# IDE0037: Use inferred member name +dotnet_diagnostic.IDE0037.severity = silent diff --git a/.gitignore b/.gitignore index 954c945..c57855c 100644 --- a/.gitignore +++ b/.gitignore @@ -397,4 +397,5 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -/Graeae.Models/OpenApi.Models.xml +*.xml + diff --git a/Graeae.AspNet.Analyzer/AdditionalOperationsAnalyzer.cs b/Graeae.AspNet.Analyzer/AdditionalOperationsAnalyzer.cs new file mode 100644 index 0000000..09f25c4 --- /dev/null +++ b/Graeae.AspNet.Analyzer/AdditionalOperationsAnalyzer.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using Graeae.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Yaml2JsonNode; + +namespace Graeae.AspNet.Analyzer; + +/// +/// Outputs diagnostics for handlers that handle routes or operations that are not listed in the OAI description. +/// +[Generator(LanguageNames.CSharp)] +internal class AdditionalOperationsAnalyzer : IIncrementalGenerator +{ + private static OpenApiDocument[]? OpenApiDocs { get; set; } + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + OpenApiDocs = null; + + var handlerClasses = context.SyntaxProvider.CreateSyntaxProvider(HandlerClassPredicate, HandlerClassTransform) + .Where(x => x is not null); + + var files = context.AdditionalTextsProvider.Where(static file => file.Path.EndsWith("openapi.yaml")); + var namesAndContents = files.Select((f, ct) => (Name: Path.GetFileNameWithoutExtension(f.Path), Content: f.GetText(ct)?.ToString(), Path: f.Path)); + + context.RegisterSourceOutput(handlerClasses.Combine(namesAndContents.Collect()), AddDiagnostics); + } + + private static bool HandlerClassPredicate(SyntaxNode node, CancellationToken token) + { + return node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; + } + + private static (string, ClassDeclarationSyntax)? HandlerClassTransform(GeneratorSyntaxContext context, CancellationToken token) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + var symbol = context.SemanticModel.GetDeclaredSymbol(context.Node); + + if (symbol is INamedTypeSymbol && + classDeclaration.TryGetAttribute("Graeae.AspNet.RequestHandlerAttribute", context.SemanticModel, token, out var attribute) && + attribute!.TryGetStringParameter(out var route)) + { + return (route!, classDeclaration); + } + + return null; + } + + private static void AddDiagnostics(SourceProductionContext context, ((string Route, ClassDeclarationSyntax Type)? Handler, ImmutableArray<(string Name, string? Content, string Path)> Files) source) + { + try + { + OpenApiDocs ??= source.Files.Select(file => + { + if (file.Content == null) + throw new Exception("Failed to read file \"" + file.Path + "\""); + var doc = YamlSerializer.Deserialize(file.Content); + doc!.Initialize().Wait(); + + return doc; + }).ToArray(); + + var handler = source.Handler!.Value; + + var allPaths = OpenApiDocs.SelectMany(x => x.Paths).ToList(); + var path = allPaths.FirstOrDefault(x => x.Key.ToString() == handler.Route); + if (path.Key is null) + { + context.ReportDiagnostic(Diagnostics.AdditionalRouteHandler(handler.Route)); + return; + } + + var route = path.Key; + var pathItem = path.Value; + + var methods = handler.Type.Members.OfType().ToArray(); + + foreach (var method in methods) + { + var (op, name) = GetMatchingOperation(method, pathItem); + if (!OperationExists(route, op, method)) + context.ReportDiagnostic(Diagnostics.AdditionalRouteOperationHandler(route.ToString(), name!)); + } + } + catch (Exception e) + { + //Debug.Break(); + var errorMessage = $"Error: {e.Message}\n\nStack trace: {e.StackTrace}\n\nStack trace: {e.InnerException?.StackTrace}"; + context.ReportDiagnostic(Diagnostics.OperationalError(errorMessage)); + } + } + + private static (Operation? Op, string? Name) GetMatchingOperation(MethodDeclarationSyntax method, PathItem pathItem) => + method.Identifier.ValueText.ToUpperInvariant() switch + { + "GET" => (pathItem.Get, nameof(pathItem.Get)), + "POST" => (pathItem.Post, nameof(pathItem.Post)), + "PUT" => (pathItem.Put, nameof(pathItem.Put)), + "DELETE" => (pathItem.Delete, nameof(pathItem.Delete)), + "TRACE" => (pathItem.Trace, nameof(pathItem.Trace)), + "OPTIONS" => (pathItem.Options, nameof(pathItem.Options)), + "HEAD" => (pathItem.Head, nameof(pathItem.Head)), + _ => (null, null) + }; + + private static bool OperationExists(PathTemplate route, Operation? op, MethodDeclarationSyntax method) + { + if (op is null) return false; + + // parameters can be implicitly or explicitly bound + // + // - path + // - implicitly bound by name + // - explicitly bound with [FromRoute(Name = "name")] + // - query + // - implicitly bound by name + // - explicitly bound with [FromQuery(Name = "name")] + // - header + // - explicitly bound with [FromHeader(Name = "name")] + // - body + // - implicitly bound by model + + var implicitOpenApiParameters = route.Segments.Select(x => + { + var match = PathHelpers.TemplatedSegmentPattern.Match(x); + return match.Success + ? new Parameter(match.Groups["param"].Value, ParameterLocation.Path) + : null; + }).Where(x => x is not null); + if (op.RequestBody is not null) + implicitOpenApiParameters = implicitOpenApiParameters.Append(Parameter.Body); + var explicitOpenapiParameters = op.Parameters?.Select(x => new Parameter(x.Name, x.In)) ?? []; + var openApiParameters = implicitOpenApiParameters.Union(explicitOpenapiParameters).ToArray(); + var methodParameterList = method.ParameterList.Parameters.SelectMany(AnalysisExtensions.GetParameters); + + return openApiParameters.All(x => methodParameterList.Contains(x)); + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Analyzer/AnalysisExtensions.cs b/Graeae.AspNet.Analyzer/AnalysisExtensions.cs new file mode 100644 index 0000000..4367436 --- /dev/null +++ b/Graeae.AspNet.Analyzer/AnalysisExtensions.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Graeae.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Graeae.AspNet.Analyzer; + +internal static class AnalysisExtensions +{ + public static bool TryGetAttribute(this ClassDeclarationSyntax candidate, string attributeName, SemanticModel semanticModel, CancellationToken cancellationToken, out AttributeSyntax? value) + { + foreach (var attributeList in candidate.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var info = semanticModel.GetSymbolInfo(attribute, cancellationToken); + var symbol = info.Symbol; + + if (symbol is IMethodSymbol method + && method.ContainingType.ToDisplayString().Equals(attributeName, StringComparison.Ordinal)) + { + value = attribute; + return true; + } + } + } + + value = null; + return false; + } + + public static bool TryGetStringParameter(this AttributeSyntax attribute, out string? value) + { + if (attribute.ArgumentList is + { + Arguments.Count: 1, + } argumentList) + { + var argument = argumentList.Arguments[0]; + + if (argument.Expression is LiteralExpressionSyntax literal) + { + value = literal.Token.Value?.ToString(); + return true; + } + } + + value = null; + return false; + } + + public static IEnumerable GetParameters(this ParameterSyntax parameter) + { + if (TryGetAttribute(parameter.AttributeLists, "FromRoute", out var attribute) && + TryGetStringParameter(attribute!, out var name)) + yield return new Parameter(name!, ParameterLocation.Path); + else if (TryGetAttribute(parameter.AttributeLists, "FromQuery", out attribute) && + TryGetStringParameter(attribute!, out name)) + yield return new Parameter(name!, ParameterLocation.Query); + else if (TryGetAttribute(parameter.AttributeLists, "FromHeader", out attribute) && + TryGetStringParameter(attribute!, out name)) + yield return new Parameter(name!, ParameterLocation.Header); + else if (TryGetAttribute(parameter.AttributeLists, "FromBody", out _)) + yield return Parameter.Body; + else if (TryGetAttribute(parameter.AttributeLists, "FromServices", out _)) + { + } + else + { + // if no attributes are found then consider all implicit options + yield return new Parameter(parameter.Identifier.ValueText, ParameterLocation.Path); + yield return new Parameter(parameter.Identifier.ValueText, ParameterLocation.Query); + // TODO: this is catching services and the http context + //yield return Parameter.Body; + } + } + + private static bool TryGetAttribute(SyntaxList attributeLists, string attributeName, out AttributeSyntax? attribute) + { + foreach (var attributeList in attributeLists) + { + foreach (var att in attributeList.Attributes) + { + if (att.Name.ToString() == attributeName) + { + attribute = att; + return true; + } + } + } + + attribute = null; + return false; + } + + /// + /// determine the namespace the class/enum/struct is declared in, if any + /// + public static string GetNamespace(this BaseTypeDeclarationSyntax syntax) + { + // If we don't have a namespace at all we'll return an empty string + // This accounts for the "default namespace" case + string nameSpace = string.Empty; + + // Get the containing syntax node for the type declaration + // (could be a nested type, for example) + SyntaxNode? potentialNamespaceParent = syntax.Parent; + + // Keep moving "out" of nested classes etc until we get to a namespace + // or until we run out of parents + while (potentialNamespaceParent != null && + potentialNamespaceParent is not NamespaceDeclarationSyntax + && potentialNamespaceParent is not FileScopedNamespaceDeclarationSyntax) + { + potentialNamespaceParent = potentialNamespaceParent.Parent; + } + + // Build up the final namespace by looping until we no longer have a namespace declaration + if (potentialNamespaceParent is BaseNamespaceDeclarationSyntax namespaceParent) + { + // We have a namespace. Use that as the type + nameSpace = namespaceParent.Name.ToString(); + + // Keep moving "out" of the namespace declarations until we + // run out of nested namespace declarations + while (true) + { + if (namespaceParent.Parent is not NamespaceDeclarationSyntax parent) + { + break; + } + + // Add the outer namespace as a prefix to the final namespace + nameSpace = $"{namespaceParent.Name}.{nameSpace}"; + namespaceParent = parent; + } + } + + // return the final namespace + return nameSpace; + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Analyzer/Debug.cs b/Graeae.AspNet.Analyzer/Debug.cs new file mode 100644 index 0000000..f3e564a --- /dev/null +++ b/Graeae.AspNet.Analyzer/Debug.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace Graeae.AspNet.Analyzer; + +internal static class Debug +{ + [Conditional("DEBUG")] + public static void Inject() + { + if (!Debugger.IsAttached) Debugger.Launch(); + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Analyzer/Diagnostics.cs b/Graeae.AspNet.Analyzer/Diagnostics.cs new file mode 100644 index 0000000..009b5fe --- /dev/null +++ b/Graeae.AspNet.Analyzer/Diagnostics.cs @@ -0,0 +1,30 @@ +using Microsoft.CodeAnalysis; + +namespace Graeae.AspNet.Analyzer; + +internal static class Diagnostics +{ + public static Diagnostic OperationalError(string message) => + Diagnostic.Create(new("GR0001", "Operational error", message, "Operation", DiagnosticSeverity.Error, true), Location.None, DiagnosticSeverity.Error); + + public static Diagnostic NoPaths(string openApiFilePath) => + Diagnostic.Create(new("GR0002", "No paths", $"No paths are defined in '{openApiFilePath}'", "Path coverage", DiagnosticSeverity.Info, true), Location.None, DiagnosticSeverity.Info); + + public static Diagnostic MissingRouteHandler(string route) => + Diagnostic.Create(new("GR0003", "Route not handled", $"Found no handler type for route '{route}'", "Path coverage", DiagnosticSeverity.Warning, true), Location.None, DiagnosticSeverity.Warning); + + public static Diagnostic MissingRouteOperationHandler(string route, string op) => + Diagnostic.Create(new("GR0004", "Route not handled", $"Found no handler for '{op.ToUpperInvariant()} {route}'", "Path coverage", DiagnosticSeverity.Warning, true), Location.None, DiagnosticSeverity.Warning); + + public static Diagnostic AdditionalRouteHandler(string route) => + Diagnostic.Create(new("GR0005", "Route not published", $"Found handler type for route '{route}' but it does not appear in the OpenAPI definition", "Path coverage", DiagnosticSeverity.Warning, true), Location.None, DiagnosticSeverity.Warning); + + public static Diagnostic AdditionalRouteOperationHandler(string route, string op) => + Diagnostic.Create(new("GR0006", "Route not published", $"Found handler for '{op.ToUpperInvariant()} {route}' but it does not appear in the OpenAPI definition", "Path coverage", DiagnosticSeverity.Warning, true), Location.None, DiagnosticSeverity.Warning); + + public static Diagnostic ExternalFileAdded(string filePath) => + Diagnostic.Create(new("GR0007", "Document load success", $"File {filePath} added to document resolver", "OpenAPI docs", DiagnosticSeverity.Info, true), null, filePath); + + public static Diagnostic ExternalFileNotAdded(string filePath) => + Diagnostic.Create(new DiagnosticDescriptor("GR0008", "Document load failure", $"File {filePath} could not be added to document resolver", "OpenAPI docs", DiagnosticSeverity.Warning, true), null, filePath); +} \ No newline at end of file diff --git a/Graeae.AspNet.Analyzer/Directory.Build.props b/Graeae.AspNet.Analyzer/Directory.Build.props new file mode 100644 index 0000000..512ee4c --- /dev/null +++ b/Graeae.AspNet.Analyzer/Directory.Build.props @@ -0,0 +1,239 @@ + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Graeae.AspNet.Analyzer/Graeae.AspNet.Analyzer.csproj b/Graeae.AspNet.Analyzer/Graeae.AspNet.Analyzer.csproj new file mode 100644 index 0000000..81b213e --- /dev/null +++ b/Graeae.AspNet.Analyzer/Graeae.AspNet.Analyzer.csproj @@ -0,0 +1,68 @@ + + + + netstandard2.0 + latest + enable + + + + true + true + false + true + true + true + true + + Greg Dennis + Analyzer for Graeae.AspNet + LICENSE + https://github.com/gregsdennis/Graeae + openapi.png + https://github.com/gregsdennis/Graeae + openapi json schema aspnet webapi minimalapi api roslyn analyzer + Release notes can be found at https://github.com/gregsdennis/Graeae + true + Graeae.AspNet.xml + 0.1.0-preview1 + 0.1.0 + 0.1.0.0 + false + true + true + true + ../openapi.snk + README.md + + true + + + + + + + True + \ + + + True + \ + + + + True + \ + + + + + + + + + diff --git a/Graeae.AspNet.Analyzer/MissingOperationsAnalyzer.cs b/Graeae.AspNet.Analyzer/MissingOperationsAnalyzer.cs new file mode 100644 index 0000000..c2afad8 --- /dev/null +++ b/Graeae.AspNet.Analyzer/MissingOperationsAnalyzer.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using Graeae.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Yaml2JsonNode; + +namespace Graeae.AspNet.Analyzer; + +/// +/// Outputs diagnostics when the OAI description defines routes and operations that aren't implemented. +/// +[Generator(LanguageNames.CSharp)] +internal class MissingOperationsAnalyzer : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var handlerClasses = context.SyntaxProvider.CreateSyntaxProvider(HandlerClassPredicate, HandlerClassTransform) + .Where(x => x is not null); + + var files = context.AdditionalTextsProvider.Where(static file => file.Path.EndsWith("openapi.yaml")); + var namesAndContents = files.Select((f, ct) => (Name: Path.GetFileNameWithoutExtension(f.Path), Content: f.GetText(ct)?.ToString(), Path: f.Path)); + + context.RegisterSourceOutput(namesAndContents.Combine(handlerClasses.Collect()), AddDiagnostics); + } + + private static bool HandlerClassPredicate(SyntaxNode node, CancellationToken token) + { + return node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; + } + + private static (string, ClassDeclarationSyntax)? HandlerClassTransform(GeneratorSyntaxContext context, CancellationToken token) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + var symbol = context.SemanticModel.GetDeclaredSymbol(context.Node); + + if (symbol is INamedTypeSymbol && + classDeclaration.TryGetAttribute("Graeae.AspNet.RequestHandlerAttribute", context.SemanticModel, token, out var attribute) && + attribute!.TryGetStringParameter(out var route)) + { + return (route!, classDeclaration); + } + + return null; + } + + private static void AddDiagnostics(SourceProductionContext context, ((string Name, string? Content, string Path) File, ImmutableArray<(string Route, ClassDeclarationSyntax Type)?> Handlers) source) + { + try + { + var file = source.File; + if (file.Content == null) + throw new Exception("Failed to read file \"" + file.Path + "\""); + + var doc = YamlSerializer.Deserialize(file.Content); + doc!.Initialize().Wait(); + + if (doc.Paths == null) + { + context.ReportDiagnostic(Diagnostics.NoPaths(file.Path)); + return; + } + + foreach (var entry in doc.Paths) + { + var route = entry.Key.ToString(); + var handlerType = source.Handlers.FirstOrDefault(x => x?.Route == route)?.Type; + + if (handlerType is null) + { + context.ReportDiagnostic(Diagnostics.MissingRouteHandler(route)); + continue; + } + + var methods = handlerType.Members.OfType().ToArray(); + + if (!MethodExists(entry.Key, entry.Value.Get, nameof(PathItem.Get), methods)) + context.ReportDiagnostic(Diagnostics.MissingRouteOperationHandler(route, nameof(PathItem.Get))); + if (!MethodExists(entry.Key, entry.Value.Post, nameof(PathItem.Post), methods)) + context.ReportDiagnostic(Diagnostics.MissingRouteOperationHandler(route, nameof(PathItem.Post))); + if (!MethodExists(entry.Key, entry.Value.Put, nameof(PathItem.Put), methods)) + context.ReportDiagnostic(Diagnostics.MissingRouteOperationHandler(route, nameof(PathItem.Put))); + if (!MethodExists(entry.Key, entry.Value.Delete, nameof(PathItem.Delete), methods)) + context.ReportDiagnostic(Diagnostics.MissingRouteOperationHandler(route, nameof(PathItem.Delete))); + if (!MethodExists(entry.Key, entry.Value.Trace, nameof(PathItem.Trace), methods)) + context.ReportDiagnostic(Diagnostics.MissingRouteOperationHandler(route, nameof(PathItem.Trace))); + if (!MethodExists(entry.Key, entry.Value.Options, nameof(PathItem.Options), methods)) + context.ReportDiagnostic(Diagnostics.MissingRouteOperationHandler(route, nameof(PathItem.Options))); + if (!MethodExists(entry.Key, entry.Value.Head, nameof(PathItem.Head), methods)) + context.ReportDiagnostic(Diagnostics.MissingRouteOperationHandler(route, nameof(PathItem.Head))); + } + } + catch (Exception e) + { + //Debug.Break(); + var errorMessage = $"Error: {e.Message}\n\nStack trace: {e.StackTrace}\n\nStack trace: {e.InnerException?.StackTrace}"; + context.ReportDiagnostic(Diagnostics.OperationalError(errorMessage)); + } + } + + private static bool MethodExists(PathTemplate route, Operation? op, string opName, IEnumerable methods) + { + if (op is null) return true; + + // parameters can be implicitly or explicitly bound + // + // - path + // - implicitly bound by name + // - explicitly bound with [FromRoute(Name = "name")] + // - query + // - implicitly bound by name + // - explicitly bound with [FromQuery(Name = "name")] + // - header + // - explicitly bound with [FromHeader(Name = "name")] + // - body + // - implicitly bound by model + + var implicitOpenApiParameters = route.Segments.Select(x => + { + var match = PathHelpers.TemplatedSegmentPattern.Match(x); + return match.Success + ? new Parameter(match.Groups["param"].Value, ParameterLocation.Path) + : null; + }).Where(x => x is not null); + if (op.RequestBody is not null) + implicitOpenApiParameters = implicitOpenApiParameters.Append(Parameter.Body); + var explicitOpenapiParameters = op.Parameters?.Select(x => new Parameter(x.Name, x.In)) ?? []; + var openApiParameters = implicitOpenApiParameters.Union(explicitOpenapiParameters).ToArray(); + var methodParameterLists = methods.Where(x => string.Equals(x.Identifier.ValueText, opName, StringComparison.InvariantCultureIgnoreCase)) + .Select(x => x.ParameterList.Parameters.SelectMany(AnalysisExtensions.GetParameters)); + + return methodParameterLists.Any(methodParameterList => openApiParameters.All(methodParameterList.Contains)); + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Analyzer/ModelGenerationAnalyzer.cs b/Graeae.AspNet.Analyzer/ModelGenerationAnalyzer.cs new file mode 100644 index 0000000..3255b13 --- /dev/null +++ b/Graeae.AspNet.Analyzer/ModelGenerationAnalyzer.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using Corvus.Json; +using Corvus.Json.CodeGeneration; +using Corvus.Json.CodeGeneration.CSharp; +using Graeae.Models; +using Json.Schema; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Yaml2JsonNode; +using Encoding = System.Text.Encoding; +using VocabularyRegistry = Corvus.Json.CodeGeneration.VocabularyRegistry; + +namespace Graeae.AspNet.Analyzer; + +[Generator(LanguageNames.CSharp)] +internal class ModelGenerationAnalyzer : IIncrementalGenerator +{ + private static string _namespace = null!; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // need to identify the namespace to generate models in + var handlerClasses = context.SyntaxProvider.CreateSyntaxProvider(HandlerClassPredicate, HandlerClassTransform) + .Where(x => x is not null); + context.RegisterSourceOutput(handlerClasses.Collect(), CacheNamespace); + + // generate the models + var files = context.AdditionalTextsProvider.Where(static file => file.Path.EndsWith("openapi.yaml")); + var namesAndContents = files.Select((f, ct) => (Name: Path.GetFileNameWithoutExtension(f.Path), Content: f.GetText(ct)?.ToString(), Path: f.Path)); + context.RegisterSourceOutput(namesAndContents.Collect(), AddDiagnostics); + } + + private static bool HandlerClassPredicate(SyntaxNode node, CancellationToken token) + { + return node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; + } + + private static string? HandlerClassTransform(GeneratorSyntaxContext context, CancellationToken token) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + return classDeclaration.GetNamespace(); + } + + private static void CacheNamespace(SourceProductionContext context, ImmutableArray namespaces) + { + _namespace = (namespaces.Distinct().FirstOrDefault() ?? "Graeae.AspNet") + ".Models"; + } + + private static void AddDiagnostics(SourceProductionContext context, ImmutableArray<(string Name, string? Content, string Path)> files) + { + try + { + var documentResolver = new PrepopulatedDocumentResolver(); + var references = new List(); + + foreach (var file in files) + { + if (file.Content == null) + throw new Exception("Failed to read file \"" + file.Path + "\""); + + var yaml = YamlSerializer.Parse(file.Content); + var node = yaml.ToJsonNode().FirstOrDefault(); + if (node is null) continue; + + /* + if (!PathHelpers.TryNormalizeSchemaReference(file.Path, out var normalizedUri)) + { + context.ReportDiagnostic(Diagnostics.OperationalError($"Could not normalize path for file '{file.Path}'")); + continue; + } + /*/ + var normalizedUri = PathHelpers.Normalize(file.Path); + //*/ + + var doc = JsonDocument.Parse(node.ToString()); + documentResolver.AddDocument(normalizedUri, doc); + + var openapiDoc = node.Deserialize()!; + var schemaLocations = openapiDoc.FindSchemaLocations(normalizedUri); + foreach (var schemaLocation in schemaLocations) + { + references.Add(schemaLocation); + } + } + + RegisterMetaSchemas(documentResolver); + var typeBuilder = new JsonSchemaTypeBuilder(documentResolver, RegisterVocabularies(documentResolver)); + var typeDeclarations = references.Select(r => typeBuilder.AddTypeDeclarations(r, Corvus.Json.CodeGeneration.Draft202012.VocabularyAnalyser.DefaultVocabulary)); + var languageProvider = CSharpLanguageProvider.DefaultWithOptions(new CSharpLanguageProvider.Options(_namespace)); + var generatedCode = typeBuilder.GenerateCodeUsing(languageProvider, CancellationToken.None, typeDeclarations); + + foreach (var codeFile in generatedCode) + { + context.AddSource(codeFile.FileName, SourceText.From(codeFile.FileContent, Encoding.UTF8)); + } + } + catch (Exception e) + { + Debug.Inject(); + var errorMessage = $"Error: {e.Message}\n\nStack trace: {e.StackTrace}\n\nStack trace: {e.InnerException?.StackTrace}"; + context.ReportDiagnostic(Diagnostics.OperationalError(errorMessage)); + } + } + + private static void RegisterMetaSchemas(IDocumentResolver documentResolver) + { + void RegisterMetaSchema(string uri, JsonSchema schema) + { + var doc = JsonSerializer.SerializeToDocument(schema); + documentResolver.AddDocument(uri, doc); + } + + RegisterMetaSchema(MetaSchemas.Draft6Id.ToString(), MetaSchemas.Draft6); + RegisterMetaSchema(MetaSchemas.Draft7Id.ToString(), MetaSchemas.Draft7); + RegisterMetaSchema(MetaSchemas.Draft201909Id.ToString(), MetaSchemas.Draft201909); + RegisterMetaSchema(MetaSchemas.Draft202012Id.ToString(), MetaSchemas.Draft202012); + RegisterMetaSchema(Json.Schema.OpenApi.MetaSchemas.OpenApiMetaId.ToString(), Json.Schema.OpenApi.MetaSchemas.OpenApiMeta); + + } + + private static VocabularyRegistry RegisterVocabularies(IDocumentResolver documentResolver) + { + VocabularyRegistry vocabularyRegistry = new(); + + // Add support for the vocabularies we are interested in. + Corvus.Json.CodeGeneration.Draft6.VocabularyAnalyser.RegisterAnalyser(vocabularyRegistry); + Corvus.Json.CodeGeneration.Draft7.VocabularyAnalyser.RegisterAnalyser(vocabularyRegistry); + Corvus.Json.CodeGeneration.Draft201909.VocabularyAnalyser.RegisterAnalyser(documentResolver, vocabularyRegistry); + Corvus.Json.CodeGeneration.Draft202012.VocabularyAnalyser.RegisterAnalyser(documentResolver, vocabularyRegistry); + Corvus.Json.CodeGeneration.OpenApi30.VocabularyAnalyser.RegisterAnalyser(vocabularyRegistry); + + // And register the custom vocabulary for Corvus extensions. + vocabularyRegistry.RegisterVocabularies(Corvus.Json.CodeGeneration.CorvusVocabulary.SchemaVocabulary.DefaultInstance); + return vocabularyRegistry; + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Analyzer/OpenApiDocumentExtensions.cs b/Graeae.AspNet.Analyzer/OpenApiDocumentExtensions.cs new file mode 100644 index 0000000..d9e6bb5 --- /dev/null +++ b/Graeae.AspNet.Analyzer/OpenApiDocumentExtensions.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Corvus.Json; +using Graeae.Models; +using Json.Schema; +using JsonPointer = Json.Pointer.JsonPointer; + +namespace Graeae.AspNet.Analyzer; + +internal static class OpenApiDocumentExtensions +{ + public static IEnumerable FindSchemaLocations(this OpenApiDocument openApiDocument, string documentPath) + { + return GetSchemas(JsonPointer.Create("components"), openApiDocument.Components) + .Concat(GetSchemas(JsonPointer.Create("paths"), openApiDocument.Paths)) + .Concat(GetSchemas(JsonPointer.Create("webhooks"), openApiDocument.Webhooks)) + .Select(x => new JsonReference(new Uri(documentPath).ToString(), $"#{x}")); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, ComponentCollection? components) + { + if (components is null) return []; + + return GetSchemas(baseRoute.Combine("schemas"), components.Schemas) + .Concat(GetSchemas(baseRoute.Combine("responses"), components.Responses)) + .Concat(GetSchemas(baseRoute.Combine("parameters"), components.Parameters)) + .Concat(GetSchemas(baseRoute.Combine("requestBodies"), components.RequestBodies)) + .Concat(GetSchemas(baseRoute.Combine("headers"), components.Headers)) + .Concat(GetSchemas(baseRoute.Combine("callbacks"), components.Callbacks)) + .Concat(GetSchemas(baseRoute.Combine("pathItems"), components.PathItems)); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IDictionary? schemas) + { + if (schemas is null) return []; + + return schemas.Select(x => baseRoute.Combine(x.Key)); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IDictionary? responses) + { + if (responses is null) return []; + + return responses.SelectMany(x => + { + var b = baseRoute.Combine(x.Key.GetKeyString()); + return GetSchemas(b.Combine("headers"), x.Value.Headers) + .Concat(GetSchemas(b.Combine("content"), x.Value.Content)); + }); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IDictionary? parameters) + { + if (parameters is null) return []; + + return parameters.SelectMany(x => GetSchemas(baseRoute.Combine(x.Key, "content"), x.Value.Content)); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IReadOnlyList? parameters) + { + if (parameters is null) return []; + + return parameters.SelectMany((x, i) => GetSchemas(baseRoute.Combine(i, "content"), x.Content)); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IDictionary? requestBodies) + { + if (requestBodies is null) return []; + + return requestBodies.SelectMany(x => GetSchemas(baseRoute.Combine(x.Key, "content"), x.Value.Content)); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IDictionary? headers) + { + if (headers is null) return []; + + return headers.SelectMany(x => GetSchemas(baseRoute.Combine(x.Key, "content"), x.Value.Content)); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IDictionary? callbacks) + { + if (callbacks is null) return []; + + return callbacks.SelectMany(x => GetSchemas(baseRoute.Combine(x.Key), x.Value)); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IDictionary? mediaTypes) + { + if (mediaTypes is null) return []; + + return mediaTypes.Select(x => baseRoute.Combine(x.Key, "schema")); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, IDictionary? pathItems) + { + if (pathItems is null) return []; + + return pathItems.SelectMany(x => + { + var b = baseRoute.Combine(x.Key.GetKeyString()); + return GetSchemas(b.Combine("get"), x.Value.Get) + .Concat(GetSchemas(b.Combine("put"), x.Value.Put)) + .Concat(GetSchemas(b.Combine("post"), x.Value.Post)) + .Concat(GetSchemas(b.Combine("delete"), x.Value.Delete)) + .Concat(GetSchemas(b.Combine("options"), x.Value.Options)) + .Concat(GetSchemas(b.Combine("head"), x.Value.Head)) + .Concat(GetSchemas(b.Combine("patch"), x.Value.Patch)) + .Concat(GetSchemas(b.Combine("trace"), x.Value.Trace)) + .Concat(GetSchemas(b.Combine("parameters"), x.Value.Parameters)); + }); + } + + private static IEnumerable GetSchemas(JsonPointer baseRoute, Operation? operation) + { + if (operation is null) return []; + + return GetSchemas(baseRoute.Combine("parameters"), operation.Parameters) + .Concat(GetSchemas(baseRoute.Combine("requestBody", "content"), operation.RequestBody?.Content)) + .Concat(GetSchemas(baseRoute.Combine("responses"), operation.Responses)) + .Concat(GetSchemas(baseRoute.Combine("callbacks"), operation.Callbacks)); + } + + private static string GetKeyString(this T? value) + { + var keyString = typeof(T).IsEnum + ? Convert.ToInt32(value).ToString() + : value!.ToString(); + + return keyString; + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Analyzer/Parameter.cs b/Graeae.AspNet.Analyzer/Parameter.cs new file mode 100644 index 0000000..16743e1 --- /dev/null +++ b/Graeae.AspNet.Analyzer/Parameter.cs @@ -0,0 +1,17 @@ +using Graeae.Models; + +namespace Graeae.AspNet.Analyzer; + +internal record Parameter +{ + public static readonly Parameter Body = new(string.Empty, ParameterLocation.Unspecified); + + public string Name { get; } + public ParameterLocation In { get; } + + public Parameter(string name, ParameterLocation @in) + { + Name = @in == ParameterLocation.Header ? name.ToLowerInvariant() : name; + In = @in; + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Analyzer/PathHelpers.cs b/Graeae.AspNet.Analyzer/PathHelpers.cs new file mode 100644 index 0000000..fac134f --- /dev/null +++ b/Graeae.AspNet.Analyzer/PathHelpers.cs @@ -0,0 +1,15 @@ +using Corvus.Json; +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text.RegularExpressions; + +namespace Graeae.AspNet.Analyzer; + +internal static class PathHelpers +{ + public static readonly Regex TemplatedSegmentPattern = new(@"^\{(?.*)\}$", RegexOptions.Compiled | RegexOptions.ECMAScript); + + //public static string Normalize(string path) => path.Replace("\\", "/"); + public static string Normalize(string path) => new Uri(path).ToString().Replace("file:///C:", "https://graeae.net"); +} \ No newline at end of file diff --git a/Graeae.AspNet.Tests.Host/Graeae.AspNet.Tests.Host.csproj b/Graeae.AspNet.Tests.Host/Graeae.AspNet.Tests.Host.csproj new file mode 100644 index 0000000..16227cf --- /dev/null +++ b/Graeae.AspNet.Tests.Host/Graeae.AspNet.Tests.Host.csproj @@ -0,0 +1,29 @@ + + + + Exe + net8.0 + enable + enable + + + + true + $(BaseIntermediateOutputPath)Generated + + + + + + + + + + + + + + + + + diff --git a/Graeae.AspNet.Tests.Host/Program.cs b/Graeae.AspNet.Tests.Host/Program.cs new file mode 100644 index 0000000..af7642e --- /dev/null +++ b/Graeae.AspNet.Tests.Host/Program.cs @@ -0,0 +1,12 @@ +using Graeae.AspNet; + +var builder = WebApplication.CreateBuilder(); + +var app = builder.Build(); + +await app.MapOpenApi("openapi.yaml", new OpenApiOptions +{ + IgnoreUnhandledPaths = true +}); + +app.Run(); \ No newline at end of file diff --git a/Graeae.AspNet.Tests.Host/Properties/launchSettings.json b/Graeae.AspNet.Tests.Host/Properties/launchSettings.json new file mode 100644 index 0000000..271e4e5 --- /dev/null +++ b/Graeae.AspNet.Tests.Host/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Graeae.AspNet.Tests.Host": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:57341;http://localhost:57342" + } + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Tests.Host/RequestHandlers/GoodbyeHandler.cs b/Graeae.AspNet.Tests.Host/RequestHandlers/GoodbyeHandler.cs new file mode 100644 index 0000000..28e711b --- /dev/null +++ b/Graeae.AspNet.Tests.Host/RequestHandlers/GoodbyeHandler.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; +using Graeae.AspNet.Tests.Host.RequestHandlers.Models; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler("/goodbye")] +public static class GoodbyeHandler +{ + public static IResult Get(HttpContext context, [FromBody] Person? person) + { + return TypedResults.Ok($"Hello, {person?.Name ?? "World"}"); + } +} diff --git a/Graeae.AspNet.Tests.Host/RequestHandlers/HelloHandler.cs b/Graeae.AspNet.Tests.Host/RequestHandlers/HelloHandler.cs new file mode 100644 index 0000000..349f277 --- /dev/null +++ b/Graeae.AspNet.Tests.Host/RequestHandlers/HelloHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler("/hello")] +public static class HelloHandler +{ + public static IResult Get(HttpContext context, [FromQuery] string? name) + { + return TypedResults.Ok($"Hello, {name ?? "World"}"); + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Tests.Host/RequestHandlers/HelloNameHandler.cs b/Graeae.AspNet.Tests.Host/RequestHandlers/HelloNameHandler.cs new file mode 100644 index 0000000..2654c6a --- /dev/null +++ b/Graeae.AspNet.Tests.Host/RequestHandlers/HelloNameHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler("/hello/{name}")] +public static class HelloNameHandler +{ + public static IResult Get(HttpContext context, [FromRoute] string? name) + { + return TypedResults.Ok($"Hello, {name ?? "World"}"); + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Tests.Host/openapi.yaml b/Graeae.AspNet.Tests.Host/openapi.yaml new file mode 100644 index 0000000..e14673c --- /dev/null +++ b/Graeae.AspNet.Tests.Host/openapi.yaml @@ -0,0 +1,68 @@ +openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + responses: + '200': + description: okay + content: + application/json: + schema: + type: string + parameters: + - name: name + in: query + required: false + schema: + type: string + post: + description: hello world + responses: + '200': + description: okay + content: + application/json: + schema: + type: string + parameters: + - name: name + in: query + required: false + schema: + $ref: '#/components/schemas/Person' + /goodbye: + get: + description: goodbye world + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + required: + - name + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +components: + schemas: + Person: + type: object + properties: + name: + type: string + age: + type: integer + required: + - name + - age diff --git a/Graeae.AspNet.Tests/Analyzer/MissingOperationsAnalyzerTests.cs b/Graeae.AspNet.Tests/Analyzer/MissingOperationsAnalyzerTests.cs new file mode 100644 index 0000000..fd8db86 --- /dev/null +++ b/Graeae.AspNet.Tests/Analyzer/MissingOperationsAnalyzerTests.cs @@ -0,0 +1,922 @@ +using Graeae.AspNet.Analyzer; +using Microsoft.CodeAnalysis.Testing; +using VerifyCS = Graeae.AspNet.Tests.Analyzer.Verifiers.CSharpSourceGeneratorVerifier; + +namespace Graeae.AspNet.Tests.Analyzer; + +public class MissingOperationsAnalyzerTests +{ + private const string AttributeContent = @"using System; + +namespace Graeae.AspNet; + +[AttributeUsage(AttributeTargets.Class)] +public class RequestHandlerAttribute : Attribute +{ + public string Path { get; } + + public RequestHandlerAttribute(string path) + { + Path = path; + } +}"; + + [Test] + public async Task FoundGetHelloWarnGoodbye() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + responses: + '200': + description: okay + content: + application/json: + schema: + type: string + /goodbye: + get: + description: goodbye world + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context) + { + return Task.FromResult(""hello world""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + ExpectedDiagnostics = + { + new DiagnosticResult(Diagnostics.MissingRouteHandler("/goodbye").Descriptor) + } + } + }.RunAsync(); + } + + [Test] + public async Task MethodExistsWrongParams_Body() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + post: + description: goodbye world + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + required: + - name + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Post(HttpContext context) + { + return Task.FromResult(""hello world""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + ExpectedDiagnostics = + { + new DiagnosticResult(Diagnostics.MissingRouteOperationHandler("/hello", "Post").Descriptor) + } + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_ImplicitBody() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + post: + description: goodbye world + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + required: + - name + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Post(HttpContext context, HelloPostBodyModel model) + { + return Task.FromResult($""hello {model.Name}""); + } +}"; + var model = @"namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +public class HelloPostBodyModel +{ + public string Name { get; set; } +} +"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent, model }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_ExplicitBody() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + post: + description: goodbye world + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + required: + - name + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Post(HttpContext context, [FromBody] HelloPostBodyModel model) + { + return Task.FromResult($""hello {model.Name}""); + } +}"; + var model = @"namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +public class HelloPostBodyModel +{ + public string Name { get; set; } +} +"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent, model }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + } + }.RunAsync(); + } + + [Test] + public async Task MethodExistsWrongParams_Query() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + parameters: + - name: name + in: query + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context) + { + return Task.FromResult($""hello world""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + ExpectedDiagnostics = + { + new DiagnosticResult(Diagnostics.MissingRouteOperationHandler("/hello", "Get").Descriptor) + } + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_ImplicitQuery() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + parameters: + - name: name + in: query + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, string name) + { + return Task.FromResult($""hello {name ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_ExplicitQuery() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + parameters: + - name: n + in: query + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, [FromQuery(Name = ""n"")] string name) + { + return Task.FromResult($""hello {name ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_ExplicitQuery_UnmatchedName() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + parameters: + - name: name + in: query + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, [FromQuery(Name = ""n"")] string name) + { + return Task.FromResult($""hello {name ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + ExpectedDiagnostics = + { + new DiagnosticResult(Diagnostics.MissingRouteOperationHandler("/hello", "Get").Descriptor) + } + } + }.RunAsync(); + } + + [Test] + public async Task MethodExistsWrongParams_Path() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello/{name}: + get: + description: hello world + parameters: + - name: name + in: path + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello/{name}"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context) + { + return Task.FromResult($""hello world""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + ExpectedDiagnostics = + { + new DiagnosticResult(Diagnostics.MissingRouteOperationHandler("/hello/{name}", "Get").Descriptor) + } + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_ImplicitPath() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello/{name}: + get: + description: hello world + parameters: + - name: name + in: path + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello/{name}"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, string name) + { + return Task.FromResult($""hello {name ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_ExplicitPath() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello/{name}: + get: + description: hello world + parameters: + - name: name + in: path + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello/{name}"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, [FromRoute(Name = ""name"")] string foo) + { + return Task.FromResult($""hello {foo ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_ExplicitPath_UnmatchedName() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello/{name}: + get: + description: hello world + parameters: + - name: name + in: path + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello/{name}"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, [FromRoute(Name = ""n"")] string name) + { + return Task.FromResult($""hello {name ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + ExpectedDiagnostics = + { + new DiagnosticResult(Diagnostics.MissingRouteOperationHandler("/hello/{name}", "Get").Descriptor) + } + } + }.RunAsync(); + } + + [Test] + public async Task MethodExistsWrongParams_Header() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + parameters: + - name: name + in: header + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context) + { + return Task.FromResult($""hello world""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + ExpectedDiagnostics = + { + new DiagnosticResult(Diagnostics.MissingRouteOperationHandler("/hello", "Get").Descriptor) + } + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_Header() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + parameters: + - name: x-name + in: header + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, [FromHeader(Name = ""x-name"")] string name) + { + return Task.FromResult($""hello {name ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_Header_DifferentCasing() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + parameters: + - name: X-Name + in: header + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, [FromHeader(Name = ""x-name"")] string name) + { + return Task.FromResult($""hello {name ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + } + }.RunAsync(); + } + + [Test] + public async Task FoundGetHello_Header_UnmatchedName() + { + var openapiContent = @"openapi: 3.1.0 +info: + title: Graeae Generation Test Host + version: 1.0.0 +paths: + /hello: + get: + description: hello world + parameters: + - name: name + in: header + required: false + schema: + type: string + format: int32 + responses: + '200': + description: okay + content: + application/json: + schema: + type: string +"; + var handler = @"using System.Threading.Tasks; +using Graeae.AspNet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Graeae.AspNet.Tests.Host.RequestHandlers; + +[RequestHandler(""/hello"")] +public static class HelloHandler +{ + public static Task Get(HttpContext context, [FromHeader(Name = ""x-custom"")] string name) + { + return Task.FromResult($""hello {name ?? ""world""}""); + } +}"; + + await new VerifyCS.Test + { + TestState = + { + Sources = { handler, AttributeContent }, + AdditionalFiles = { ("openapi.yaml", openapiContent) }, + ReferenceAssemblies = PackageHelper.AspNetWeb, + ExpectedDiagnostics = + { + new DiagnosticResult(Diagnostics.MissingRouteOperationHandler("/hello", "Get").Descriptor) + } + } + }.RunAsync(); + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Tests/Analyzer/PackageHelper.cs b/Graeae.AspNet.Tests/Analyzer/PackageHelper.cs new file mode 100644 index 0000000..5352485 --- /dev/null +++ b/Graeae.AspNet.Tests/Analyzer/PackageHelper.cs @@ -0,0 +1,17 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Testing; + +namespace Graeae.AspNet.Tests.Analyzer; + +internal static class PackageHelper +{ + public static readonly ReferenceAssemblies AspNetWeb = ReferenceAssemblies.Net.Net60.AddPackages( + ("Microsoft.AspNetCore.App.Ref", "6.0.23") + ); + + public static ReferenceAssemblies AddPackages(this ReferenceAssemblies start, params (string name, string version)[] packages) => + start.AddPackages(packages + .Select(x => new PackageIdentity(x.name, x.version)) + .ToImmutableArray() + ); +} \ No newline at end of file diff --git a/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpSourceGeneratorVerifier.Test.cs b/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpSourceGeneratorVerifier.Test.cs new file mode 100644 index 0000000..c301ff5 --- /dev/null +++ b/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpSourceGeneratorVerifier.Test.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; +#pragma warning disable CS0618 // Type or member is obsolete + +namespace Graeae.AspNet.Tests.Analyzer.Verifiers; + +internal static partial class CSharpSourceGeneratorVerifier + where TSourceGenerator : IIncrementalGenerator, new() +{ + public sealed class Test : CSharpSourceGeneratorTest + { + public Test() + { + } + + public LanguageVersion LanguageVersion { get; set; } = LanguageVersion.Default; + + protected override IEnumerable GetSourceGenerators() + { + return new[] { typeof(TSourceGenerator) }; + } + + protected override CompilationOptions CreateCompilationOptions() + { + var compilationOptions = base.CreateCompilationOptions(); + return compilationOptions.WithSpecificDiagnosticOptions( + compilationOptions.SpecificDiagnosticOptions.SetItems(CSharpVerifierHelper.NullableWarnings)); + } + + protected override ParseOptions CreateParseOptions() + { + return ((CSharpParseOptions)base.CreateParseOptions()).WithLanguageVersion(LanguageVersion); + } + } +} diff --git a/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpSourceGeneratorVerifier.cs b/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpSourceGeneratorVerifier.cs new file mode 100644 index 0000000..c7f85af --- /dev/null +++ b/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpSourceGeneratorVerifier.cs @@ -0,0 +1,63 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; + +namespace Graeae.AspNet.Tests.Analyzer.Verifiers; + +internal static partial class CSharpSourceGeneratorVerifier + where TSourceGenerator : IIncrementalGenerator, new() +{ + internal static readonly (string filename, string content)[] EmptyGeneratedSources = Array.Empty<(string filename, string content)>(); + + public static DiagnosticResult Diagnostic() + => new DiagnosticResult(); + + public static DiagnosticResult Diagnostic(string id, DiagnosticSeverity severity) + => new DiagnosticResult(id, severity); + + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => new DiagnosticResult(descriptor); + + public static async Task VerifyGeneratorAsync(string source, (string filename, string content) generatedSource) + => await VerifyGeneratorAsync(source, DiagnosticResult.EmptyDiagnosticResults, new[] { generatedSource }); + + public static async Task VerifyGeneratorAsync(string source, params (string filename, string content)[] generatedSources) + => await VerifyGeneratorAsync(source, DiagnosticResult.EmptyDiagnosticResults, generatedSources); + + public static async Task VerifyGeneratorAsync(string source, DiagnosticResult diagnostic) + => await VerifyGeneratorAsync(source, new[] { diagnostic }, EmptyGeneratedSources); + + public static async Task VerifyGeneratorAsync(string source, params DiagnosticResult[] diagnostics) + => await VerifyGeneratorAsync(source, diagnostics, EmptyGeneratedSources); + + public static async Task VerifyGeneratorAsync(string source, DiagnosticResult diagnostic, (string filename, string content) generatedSource) + => await VerifyGeneratorAsync(source, new[] { diagnostic }, new[] { generatedSource }); + + public static async Task VerifyGeneratorAsync(string source, DiagnosticResult[] diagnostics, (string filename, string content) generatedSource) + => await VerifyGeneratorAsync(source, diagnostics, new[] { generatedSource }); + + public static async Task VerifyGeneratorAsync(string source, DiagnosticResult diagnostic, params (string filename, string content)[] generatedSources) + => await VerifyGeneratorAsync(source, new[] { diagnostic }, generatedSources); + + public static async Task VerifyGeneratorAsync(string source, DiagnosticResult[] diagnostics, params (string filename, string content)[] generatedSources) + { + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { source }, + }, + ReferenceAssemblies = ReferenceAssemblies.Net.Net60, + }; + + foreach ((string filename, string content) in generatedSources) + { + test.TestState.GeneratedSources.Add((typeof(TSourceGenerator), filename, SourceText.From(content, Encoding.UTF8))); + } + + test.ExpectedDiagnostics.AddRange(diagnostics); + + await test.RunAsync(CancellationToken.None); + } +} diff --git a/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpVerifierHelper.cs b/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpVerifierHelper.cs new file mode 100644 index 0000000..c823832 --- /dev/null +++ b/Graeae.AspNet.Tests/Analyzer/Verifiers/CSharpVerifierHelper.cs @@ -0,0 +1,19 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Graeae.AspNet.Tests.Analyzer.Verifiers; + +internal static class CSharpVerifierHelper +{ + internal static ImmutableDictionary NullableWarnings { get; } = GetNullableWarningsFromCompiler(); + + private static ImmutableDictionary GetNullableWarningsFromCompiler() + { + string[] args = { "/warnaserror:nullable" }; + CSharpCommandLineArguments commandLineArguments = CSharpCommandLineParser.Default.Parse(args, Environment.CurrentDirectory, Environment.CurrentDirectory); + ImmutableDictionary nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions; + + return nullableWarnings; + } +} diff --git a/Graeae.AspNet.Tests/Graeae.AspNet.Tests.csproj b/Graeae.AspNet.Tests/Graeae.AspNet.Tests.csproj new file mode 100644 index 0000000..eab72c0 --- /dev/null +++ b/Graeae.AspNet.Tests/Graeae.AspNet.Tests.csproj @@ -0,0 +1,47 @@ + + + + net8.0 + enable + enable + $(NoWarn);CA1707;NUnit2005 + + false + + latest + + true + ../openapi.snk + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + Always + + + + diff --git a/Graeae.AspNet.Tests/RuntimeExpressionResolutionTests.cs b/Graeae.AspNet.Tests/RuntimeExpressionResolutionTests.cs new file mode 100644 index 0000000..5f08fd4 --- /dev/null +++ b/Graeae.AspNet.Tests/RuntimeExpressionResolutionTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text.Json.Nodes; +using Graeae.Models; +using Json.More; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Encoding = System.Text.Encoding; + +namespace Graeae.AspNet.Tests; + +public class RuntimeExpressionResolutionTests +{ + private HttpContext _context; + private PathTemplate _pathTemplate; + + [OneTimeSetUp] + public void Setup() + { + var requestContent = new JsonObject + { + ["id"] = 1, + ["name"] = "item 1", + }.AsJsonString(); + var requestBody = new MemoryStream(); + var requestWriter = new StreamWriter(requestBody, Encoding.UTF8); + requestWriter.Write(requestContent); + requestWriter.Flush(); + + var responseContent = new JsonObject + { + ["id"] = 1, + ["name"] = "item 1", + ["price"] = 3.14 + }.AsJsonString(); + var responseBody = new MemoryStream(); + var responseWriter = new StreamWriter(responseBody, Encoding.UTF8); + responseWriter.Write(responseContent); + responseWriter.Flush(); + + _context = new DefaultHttpContext + { + Request = + { + Method = "PUT", + IsHttps = true, + Protocol = "https", + ContentType = "application/json", // .net copies to headers + Host = new HostString("graeae.net"), + Path = "/items/1", + Body = requestBody, + Query = new QueryCollection(new Dictionary { ["param"] = "value" }), + Headers = + { + ["x-Authorization"] = "Bearer random_key" + } + }, + Response = + { + Body = responseBody, + StatusCode = (int)HttpStatusCode.OK, + ContentType = "application/json" + } + }; + + _pathTemplate = PathTemplate.Parse("/items/{item}"); + } + + [TestCase("$url", "https://graeae.net/items/1?param=value")] + [TestCase("$method", "PUT")] + [TestCase("$statusCode", "200")] + [TestCase("$request.header.Content-Type", "application/json")] + [TestCase("$request.header.x-Authorization", "Bearer random_key")] + [TestCase("$request.path.item", "1")] + [TestCase("$request.query.param", "value")] + [TestCase("$request.body#/name", "item 1")] + [TestCase("$response.body#/price", "3.14")] + [TestCase("$response.header.Content-Type", "application/json")] + public void Test1(string runtimeExpression, string expected) + { + var expr = RuntimeExpression.Parse(runtimeExpression); + var actual = expr.Resolve(_context, _pathTemplate); + + Assert.That(actual, Is.EqualTo(expected)); + } +} \ No newline at end of file diff --git a/Graeae.AspNet.Tests/Usings.cs b/Graeae.AspNet.Tests/Usings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/Graeae.AspNet.Tests/Usings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/Graeae.AspNet/Evaluator.cs b/Graeae.AspNet/Evaluator.cs new file mode 100644 index 0000000..3f17830 --- /dev/null +++ b/Graeae.AspNet/Evaluator.cs @@ -0,0 +1,122 @@ +using System.Text; +using System.Text.Json.Nodes; +using Graeae.Models; +using Json.More; +using Json.Pointer; +using Json.Schema; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; + +namespace Graeae.AspNet; + +/// +/// Extensions to evaluate runtime expressions against an HTTP call. +/// +public static class Evaluator +{ + /// + /// Resolves a callback key expression. + /// + /// The callback key expression + /// The HTTP context + /// (optional) A path template + /// The resolved expression as a URI + public static Uri Resolve(this CallbackKeyExpression expr, HttpContext context, PathTemplate? pathTemplate = null) + { + var sb = new StringBuilder(expr.ToString()); + + foreach (var parameter in expr.Parameters) + { + var value = Resolve(parameter, context, pathTemplate); + sb.Replace(parameter.ToString(), value); + } + + return new Uri(sb.ToString()); + } + + /// + /// Resolves a runtime expression. + /// + /// The runtime expression + /// The HTTP context + /// (optional) A path template + /// The resolved expression + public static string? Resolve(this RuntimeExpression expr, HttpContext context, PathTemplate? pathTemplate = null) + { + return expr.ExpressionType switch + { + RuntimeExpressionType.Url => context.Request.GetEncodedUrl(), + RuntimeExpressionType.Method => context.Request.Method, + RuntimeExpressionType.StatusCode => context.Response.StatusCode.ToString(), + RuntimeExpressionType.Request => GetFromRequest(expr, context.Request, pathTemplate), + RuntimeExpressionType.Response => GetFromResponse(expr, context.Response), + _ => throw new ArgumentOutOfRangeException(nameof(expr)) + }; + } + + private static string? GetFromRequest(RuntimeExpression expr, HttpRequest request, PathTemplate? pathTemplate) + { + switch (expr.SourceType) + { + case RuntimeExpressionSourceType.Header: + request.Headers.TryGetValue(expr.Token!, out var header); + return header; + case RuntimeExpressionSourceType.Query: + request.Query.TryGetValue(expr.Name!, out var queryParam); + return queryParam; + case RuntimeExpressionSourceType.Path: + ArgumentNullException.ThrowIfNull(pathTemplate); + var actualPath = PathTemplate.Parse(request.Path.Value!); + var matched = pathTemplate.Segments.Zip(actualPath.Segments, (x, y) => (Template: x, Actual: y)); + var target = $"{{{expr.Name}}}"; + return matched.FirstOrDefault(x => x.Template == target).Actual; + case RuntimeExpressionSourceType.Body: + return GetFromStream(expr.JsonPointer!, request.Body); + case null: + case RuntimeExpressionSourceType.Unspecified: + default: + throw new ArgumentOutOfRangeException(nameof(expr)); + } + } + + private static string GetFromResponse(RuntimeExpression expr, HttpResponse response) + { + switch (expr.SourceType) + { + case RuntimeExpressionSourceType.Header: + response.Headers.TryGetValue(expr.Token!, out var header); + return header!; + case RuntimeExpressionSourceType.Query: + throw new NotSupportedException("$response.query is not supported"); + case RuntimeExpressionSourceType.Path: + throw new NotSupportedException("$response.path is not supported"); + case RuntimeExpressionSourceType.Body: + return GetFromStream(expr.JsonPointer!, response.Body); + case null: + case RuntimeExpressionSourceType.Unspecified: + default: + throw new ArgumentOutOfRangeException(nameof(expr)); + } + } + + private static string GetFromStream(JsonPointer pointer, Stream stream) + { + using var newStream = new MemoryStream(); + + var position = stream.Position; + stream.Position = 0; + stream.CopyTo(newStream); + stream.Position = position; + newStream.Position = 0; + + using var reader = new StreamReader(newStream); + var content = reader.ReadToEnd(); + + var json = JsonNode.Parse(content); + + pointer.TryEvaluate(json, out var target); + return target.GetSchemaValueType() == SchemaValueType.String + ? target!.GetValue() + : target.AsJsonString(); + } +} \ No newline at end of file diff --git a/Graeae.AspNet/Graeae.AspNet.csproj b/Graeae.AspNet/Graeae.AspNet.csproj new file mode 100644 index 0000000..96a68b1 --- /dev/null +++ b/Graeae.AspNet/Graeae.AspNet.csproj @@ -0,0 +1,56 @@ + + + + net8.0 + enable + enable + Graeae.AspNet + latest + + Greg Dennis + Asp.Net operational functionality using Graeae.Models + LICENSE + https://github.com/gregsdennis/Graeae + openapi.png + https://github.com/gregsdennis/Graeae + openapi json schema aspnet webapi minimalapi api + Release notes can be found at https://github.com/gregsdennis/Graeae + true + Graeae.AspNet.xml + 0.1.0-preview1 + 0.1.0 + 0.1.0.0 + true + snupkg + true + true + true + ../openapi.snk + README.md + + + + + True + \ + + + True + \ + + + + True + \ + + + + + + + + + + + + diff --git a/Graeae.AspNet/OpenApiOptions.cs b/Graeae.AspNet/OpenApiOptions.cs new file mode 100644 index 0000000..4c96bae --- /dev/null +++ b/Graeae.AspNet/OpenApiOptions.cs @@ -0,0 +1,17 @@ +namespace Graeae.AspNet; + +/// +/// Defines options for the extension. +/// +public class OpenApiOptions +{ + /// + /// Provides a default set of options. + /// + public static OpenApiOptions Default { get; } = new(); + + /// + /// Ignores paths/routes that do not have handlers assigned. + /// + public bool IgnoreUnhandledPaths { get; set; } +} \ No newline at end of file diff --git a/Graeae.AspNet/RequestHandlerAttribute.cs b/Graeae.AspNet/RequestHandlerAttribute.cs new file mode 100644 index 0000000..614b6d5 --- /dev/null +++ b/Graeae.AspNet/RequestHandlerAttribute.cs @@ -0,0 +1,28 @@ +using Graeae.Models; + +namespace Graeae.AspNet; + +/// +/// Indicates that the attributed class contains handler methods for the indicated route. +/// +[AttributeUsage(AttributeTargets.Class)] +public class RequestHandlerAttribute : Attribute +{ + /// + /// The route to handle. + /// + public string Path { get; } + + /// + /// Creates a new + /// + /// The route to handle + /// Thrown when the route is not a valid path template + public RequestHandlerAttribute(string path) + { + if (!PathTemplate.TryParse(path, out _)) + throw new ArgumentException($"'{path}' is not a valid path template."); + + Path = path; + } +} \ No newline at end of file diff --git a/Graeae.AspNet/WebApplicationExtensions.cs b/Graeae.AspNet/WebApplicationExtensions.cs new file mode 100644 index 0000000..8da15f8 --- /dev/null +++ b/Graeae.AspNet/WebApplicationExtensions.cs @@ -0,0 +1,101 @@ +using System.Linq.Expressions; +using System.Reflection; +using Graeae.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Yaml2JsonNode; + +namespace Graeae.AspNet; + +/// +/// Extends the app builder to scan an Open API document and automatically register methods. +/// +public static class WebApplicationExtensions +{ + /// + /// Maps request handlers (see ) contained in the current assembly. + /// + /// The application builder + /// The file name of the Open API document + /// + /// The application builder. + public static async Task MapOpenApi(this IEndpointRouteBuilder app, string openApiFileName, OpenApiOptions? options = null) + { + options ??= OpenApiOptions.Default; + + var openApiText = await File.ReadAllTextAsync(openApiFileName); + var openApiDocument = YamlSerializer.Deserialize(openApiText)!; + + await openApiDocument.Initialize(); + + if (openApiDocument.Paths != null) + { + foreach (var (pathTemplate, pathItem) in openApiDocument.Paths) + { + MapPath(app, pathTemplate, pathItem, options); + } + } + + return app; + } + + private static Type[]? _allEntryTypes; + private static Type[] AllEntryTypes => _allEntryTypes ??= Assembly.GetEntryAssembly()!.GetTypes(); + + private static void MapPath(IEndpointRouteBuilder app, PathTemplate pathTemplate, PathItem pathItem, OpenApiOptions options) + { + var path = pathTemplate.ToString(); + var type = AllEntryTypes.SingleOrDefault(x => x.GetCustomAttribute()?.Path == path); + if (type == null) + { + if (!options.IgnoreUnhandledPaths) + throw new NotImplementedException($"A handler for '{path}' was not found."); + + return; + } + + if (pathItem.Get is not null) + MapOperation(app, nameof(pathItem.Get), path, type); + if (pathItem.Post is not null) + MapOperation(app, nameof(pathItem.Post), path, type); + if (pathItem.Put is not null) + MapOperation(app, nameof(pathItem.Put), path, type); + if (pathItem.Delete is not null) + MapOperation(app, nameof(pathItem.Delete), path, type); + if (pathItem.Trace is not null) + MapOperation(app, nameof(pathItem.Trace), path, type); + if (pathItem.Head is not null) + MapOperation(app, nameof(pathItem.Head), path, type); + if (pathItem.Options is not null) + MapOperation(app, nameof(pathItem.Options), path, type); + if (pathItem.Patch is not null) + MapOperation(app, nameof(pathItem.Patch), path, type); + } + + private static void MapOperation(IEndpointRouteBuilder app, string action, string path, Type handlerType) + { + var handlerMethods = handlerType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(x => x.Name == action); + foreach (var handlerMethod in handlerMethods) + { + var handlerDelegate = CreateDelegate(handlerMethod); + app.MapMethods(path, [action], handlerDelegate); + } + } + + private static Delegate CreateDelegate(this MethodInfo methodInfo) + { + Func getType; + var types = methodInfo.GetParameters().Select(p => p.ParameterType); + + if (methodInfo.ReturnType == typeof(void)) + getType = Expression.GetActionType; + else + { + getType = Expression.GetFuncType; + types = types.Append(methodInfo.ReturnType); + } + + return Delegate.CreateDelegate(getType(types.ToArray()), methodInfo); + } +} \ No newline at end of file diff --git a/Graeae.Models.Tests/CallbackTests.cs b/Graeae.Models.Tests/CallbackTests.cs index 3df9911..11fdc8d 100644 --- a/Graeae.Models.Tests/CallbackTests.cs +++ b/Graeae.Models.Tests/CallbackTests.cs @@ -8,8 +8,8 @@ public void CallbackExpressionParse() // example from https://spec.openapis.org/oas/v3.1.0#callback-object-examples var expr = (CallbackKeyExpression) "http://notificationServer.com?transactionId={$request.body#/id}&email={$request.body#/email}"; - Assert.AreEqual(2, expr.Parameters.Length); - Assert.AreEqual("$request.body#/id", expr.Parameters[0]); - Assert.AreEqual("$request.body#/email", expr.Parameters[1]); + Assert.That(expr.Parameters.Length, Is.EqualTo(2)); + Assert.That(expr.Parameters[0], Is.EqualTo("$request.body#/id")); + Assert.That(expr.Parameters[1], Is.EqualTo("$request.body#/email")); } } \ No newline at end of file diff --git a/Graeae.Models.Tests/Graeae.Models.Tests.csproj b/Graeae.Models.Tests/Graeae.Models.Tests.csproj index eb02367..684d0f6 100644 --- a/Graeae.Models.Tests/Graeae.Models.Tests.csproj +++ b/Graeae.Models.Tests/Graeae.Models.Tests.csproj @@ -12,11 +12,17 @@ - - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Graeae.Models.Tests/PayloadValidationTests.cs b/Graeae.Models.Tests/PayloadValidationTests.cs index 92dff8d..5ceb1ae 100644 --- a/Graeae.Models.Tests/PayloadValidationTests.cs +++ b/Graeae.Models.Tests/PayloadValidationTests.cs @@ -12,7 +12,7 @@ public class PayloadValidationTests public async Task ReferencesValid(string fileName) { var schemaFileName = GetFile("schema-components.json"); - var fileText = File.ReadAllText(schemaFileName); + var fileText = await File.ReadAllTextAsync(schemaFileName); var openApiDoc = JsonSerializer.Deserialize(fileText, TestSerializerContext.Default.OpenApiDocument); var options = new EvaluationOptions @@ -23,11 +23,11 @@ public async Task ReferencesValid(string fileName) var componentRef = JsonPointer.Parse("#/components/schemas/outer"); var fullFileName = GetFile(fileName); - var payloadJson = File.ReadAllText(fullFileName); + var payloadJson = await File.ReadAllTextAsync(fullFileName); var document = JsonDocument.Parse(payloadJson); var results = openApiDoc.EvaluatePayload(document, componentRef, options); - Assert.True(results!.IsValid); + Assert.That(results!.IsValid, Is.True); } @@ -39,7 +39,7 @@ public async Task ReferencesInvalid(string fileName) { var schemaFileName = GetFile("schema-components.json"); - var fileText = File.ReadAllText(schemaFileName); + var fileText = await File.ReadAllTextAsync(schemaFileName); var openApiDoc = JsonSerializer.Deserialize(fileText, TestSerializerContext.Default.OpenApiDocument); var options = new EvaluationOptions @@ -51,10 +51,10 @@ public async Task ReferencesInvalid(string fileName) var componentRef = JsonPointer.Parse("#/components/schemas/outer"); var fullFileName = GetFile(fileName); - var payloadJson = File.ReadAllText(fullFileName); + var payloadJson = await File.ReadAllTextAsync(fullFileName); var document = JsonDocument.Parse(payloadJson); var results = openApiDoc.EvaluatePayload(document, componentRef, options); - Assert.False(results!.IsValid); + Assert.That(results!.IsValid, Is.False); } } \ No newline at end of file diff --git a/Graeae.Models.Tests/RefResolutionTests.cs b/Graeae.Models.Tests/RefResolutionTests.cs index adbd0d9..7cec653 100644 --- a/Graeae.Models.Tests/RefResolutionTests.cs +++ b/Graeae.Models.Tests/RefResolutionTests.cs @@ -64,7 +64,7 @@ public async Task SchemaRefResolvesToAnotherPartOfOpenApiDoc() var validation = start!.Evaluate(instance, options); - Assert.IsTrue(validation.IsValid); + Assert.That(validation.IsValid, Is.True); } [Test] diff --git a/Graeae.Models.Tests/RuntimeExpressionTests.cs b/Graeae.Models.Tests/RuntimeExpressionTests.cs index 69b54df..376030e 100644 --- a/Graeae.Models.Tests/RuntimeExpressionTests.cs +++ b/Graeae.Models.Tests/RuntimeExpressionTests.cs @@ -18,7 +18,7 @@ public void ParseTests_Success(string source) Console.WriteLine(backToString); #pragma warning disable NUnit2005 - Assert.AreEqual(source, backToString); + Assert.That(backToString, Is.EqualTo(source)); #pragma warning restore NUnit2005 } diff --git a/Graeae.Models.Tests/ValidationTests.cs b/Graeae.Models.Tests/ValidationTests.cs index 5c01345..4d2ae85 100644 --- a/Graeae.Models.Tests/ValidationTests.cs +++ b/Graeae.Models.Tests/ValidationTests.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.Json.Nodes; using Json.Schema; using Yaml2JsonNode; @@ -31,7 +30,7 @@ public void ValidateOpenApiDoc_3_0(string fileName) Console.WriteLine(JsonSerializer.Serialize(results, TestEnvironment.TestOutputSerializerOptions)); - Assert.IsTrue(results.IsValid); + Assert.That(results.IsValid, Is.True); } [Test] @@ -52,6 +51,6 @@ public void ValidateOpenApiDoc_3_1(string fileName) Console.WriteLine(JsonSerializer.Serialize(results, TestEnvironment.TestOutputSerializerOptions)); - Assert.IsTrue(results.IsValid); + Assert.That(results.IsValid, Is.True); } } \ No newline at end of file diff --git a/Graeae.Models/Callback.cs b/Graeae.Models/Callback.cs index 7740920..56e1a79 100644 --- a/Graeae.Models/Callback.cs +++ b/Graeae.Models/Callback.cs @@ -44,10 +44,10 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) { ExtensionData = ExtensionData.FromNode(obj); - foreach (var (key, value) in obj) + foreach (var kvp in obj) { - if (key.StartsWith("x-")) continue; - Add(CallbackKeyExpression.Parse(key), PathItem.FromNode(value, options)); + if (kvp.Key.StartsWith("x-")) continue; + Add(CallbackKeyExpression.Parse(kvp.Key), PathItem.FromNode(kvp.Value, options)); } } @@ -65,9 +65,9 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) } else { - foreach (var (key, value) in callback) + foreach (var kvp in callback) { - obj.Add(key.ToString(), PathItem.ToNode(value, options)); + obj.Add(kvp.Key.ToString(), PathItem.ToNode(kvp.Value, options)); } obj.AddExtensions(callback.ExtensionData); } @@ -79,7 +79,7 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) { if (keys.Length == 0) return null; - return this.GetFromMap(keys[0])?.Resolve(keys[1..]) ?? + return this.GetFromMap(keys[0])?.Resolve(keys.Slice(1)) ?? ExtensionData?.Resolve(keys); } @@ -158,9 +158,9 @@ bool import(JsonNode? node) void copy(Callback other) { ExtensionData = other.ExtensionData; - foreach (var (key, value) in other) + foreach (var kvp in other) { - this[key] = value; + this[kvp.Key] = kvp.Value; } } diff --git a/Graeae.Models/CallbackKeyExpression.cs b/Graeae.Models/CallbackKeyExpression.cs index c76c7c9..775f963 100644 --- a/Graeae.Models/CallbackKeyExpression.cs +++ b/Graeae.Models/CallbackKeyExpression.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Linq; +using System.Text.RegularExpressions; namespace Graeae.Models; @@ -8,7 +9,7 @@ namespace Graeae.Models; public class CallbackKeyExpression : IEquatable { private static readonly Regex TemplateVarsIdentifier = new(@"^([^{]*)(\{(?[^}]+)\}([^{])*)*$"); - + private readonly string _source; /// @@ -25,7 +26,7 @@ private CallbackKeyExpression(string source, IEnumerable para internal static CallbackKeyExpression Parse(string source) { var matches = TemplateVarsIdentifier.Matches(source); - var parameters = matches.SelectMany(x => x.Groups["runtimeExpr"].Captures.Select(c => c.Value)) + var parameters = matches.Cast().SelectMany(x => x.Groups["runtimeExpr"].Captures.Cast().Select(c => c.Value)) .Select(RuntimeExpression.Parse); return new CallbackKeyExpression(source, parameters); diff --git a/Graeae.Models/ComponentCollection.cs b/Graeae.Models/ComponentCollection.cs index 45b15da..f66e411 100644 --- a/Graeae.Models/ComponentCollection.cs +++ b/Graeae.Models/ComponentCollection.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Json.Schema; @@ -201,6 +202,8 @@ internal IEnumerable FindRefs() internal class ComponentCollectionJsonConverter : JsonConverter { + [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Deserialize(ref Utf8JsonReader, JsonSerializerOptions)")] + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] public override ComponentCollection Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var obj = JsonSerializer.Deserialize(ref reader, options) ?? @@ -209,6 +212,8 @@ public override ComponentCollection Read(ref Utf8JsonReader reader, Type typeToC return ComponentCollection.FromNode(obj, options); } + [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Deserialize(ref Utf8JsonReader, JsonSerializerOptions)")] + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] public override void Write(Utf8JsonWriter writer, ComponentCollection value, JsonSerializerOptions options) { var json = ComponentCollection.ToNode(value, options); diff --git a/Graeae.Models/Encoding.cs b/Graeae.Models/Encoding.cs index bbf01f8..15c2e6a 100644 --- a/Graeae.Models/Encoding.cs +++ b/Graeae.Models/Encoding.cs @@ -88,7 +88,7 @@ internal static Encoding FromNode(JsonNode? node, JsonSerializerOptions? options if (keys[0] == "headers") { if (keys.Length == 1) return null; - return Headers.GetFromMap(keys[1])?.Resolve(keys[2..]); + return Headers.GetFromMap(keys[1])?.Resolve(keys.Slice(2)); } return ExtensionData?.Resolve(keys); diff --git a/Graeae.Models/Example.cs b/Graeae.Models/Example.cs index ff131a7..ea6c6e3 100644 --- a/Graeae.Models/Example.cs +++ b/Graeae.Models/Example.cs @@ -1,7 +1,7 @@ -using System.Text.Json; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Json.More; namespace Graeae.Models; @@ -12,12 +12,12 @@ namespace Graeae.Models; public class Example : IRefTargetContainer { private static readonly string[] KnownKeys = - { + [ "summary", "description", "value", "externalValue" - }; + ]; /// /// Gets or sets the summary. @@ -189,6 +189,8 @@ void copy(Example other) internal class ExampleJsonConverter : JsonConverter { + [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Deserialize(ref Utf8JsonReader, JsonSerializerOptions)")] + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] public override Example Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var obj = JsonSerializer.Deserialize(ref reader, options) ?? @@ -197,6 +199,8 @@ public override Example Read(ref Utf8JsonReader reader, Type typeToConvert, Json return Example.FromNode(obj); } + [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Deserialize(ref Utf8JsonReader, JsonSerializerOptions)")] + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] public override void Write(Utf8JsonWriter writer, Example value, JsonSerializerOptions options) { var json = Example.ToNode(value); diff --git a/Graeae.Models/ExtensionData.cs b/Graeae.Models/ExtensionData.cs index 91bee23..ab09d44 100644 --- a/Graeae.Models/ExtensionData.cs +++ b/Graeae.Models/ExtensionData.cs @@ -10,9 +10,9 @@ public class ExtensionData : Dictionary, IRefTargetContainer internal static ExtensionData? FromNode(JsonObject obj) { var data = new ExtensionData(); - foreach (var (key, value) in obj.Where(x => x.Key.StartsWith("x-"))) + foreach (var kvp in obj.Where(x => x.Key.StartsWith("x-"))) { - data.Add(key, value); + data.Add(kvp.Key, kvp.Value); } return data.Any() ? data : null; @@ -26,7 +26,7 @@ public class ExtensionData : Dictionary, IRefTargetContainer if (!TryGetValue(keys[0], out var jn)) return null; if (keys.Length == 1) return jn; - keys[1..].ToPointer().TryEvaluate(jn, out var result); + keys.Slice(1).ToPointer().TryEvaluate(jn, out var result); return result; } diff --git a/Graeae.Models/Graeae.Models.csproj b/Graeae.Models/Graeae.Models.csproj index 58459ed..376de15 100644 --- a/Graeae.Models/Graeae.Models.csproj +++ b/Graeae.Models/Graeae.Models.csproj @@ -6,7 +6,7 @@ latest enable Graeae.Models - IDE0290 + IDE0290,CS1591,CS1735,IL3050,IL2026,IL2046 true Greg Dennis @@ -29,6 +29,8 @@ true ../openapi.snk README.md + + true @@ -48,9 +50,10 @@ - - + - + + + diff --git a/Graeae.Models/Graeae.Models.xml b/Graeae.Models/Graeae.Models.xml deleted file mode 100644 index 1a3c155..0000000 --- a/Graeae.Models/Graeae.Models.xml +++ /dev/null @@ -1,2378 +0,0 @@ - - - - Graeae.Models - - - - - Models a callback. - - - - - Gets or set extension data. - - - - - Models a `$ref` to a callback. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Models a callback key expression. - - - - - Gets the parameters that exist in the key expression. - - - - - (not yet implemented) Resolves the callback expression. - - Throws not implemented. - It's not implemented. - - In order to implement this, an HttpRequest or HttpResponse is required, which - means adding a reference to ASP.net. As a result, it may make more sense for - resolution functionality to exist in a secondary package. - - - - Indicates whether the current object is equal to another object of the same type. - An object to compare with this object. - - if the current object is equal to the parameter; otherwise, . - - - Returns a string that represents the current object. - A string that represents the current object. - - - - Implicitly converts a string to a via parsing. - - A - - - - Models the `components` collection. - - - - - Gets or sets the schema components. - - - - - Gets or sets the response components. - - - - - Gets or sets the parameter components. - - - - - Gets or sets the example components. - - - - - Gets or sets the request body components. - - - - - Gets or sets the header components. - - - - - Gets or sets the security scheme components. - - - - - Gets or sets the link components. - - - - - Gets or sets the callback components. - - - - - Gets or sets the path item components. - - - - - Gets or set extension data. - - - - - Models the contact information. - - - - - Gets or sets the contact name. - - - - - Gets or sets the contact URL. - - - - - Gets or sets the contact email. - - - - - Gets or set extension data. - - - - - Models an encoding object. - - - - - Gets or sets the encoding content type. - - - - - Gets or sets headers. - - - - - Gets or sets the encoding parameter style. - - - - - Gets or sets whether this will be exploded into multiple parameters. - - - - - Gets or sets whether the parameter value SHOULD allow reserved characters. - - - - - Gets or set extension data. - - - - - Models an example. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets or sets the example value. - - - - - Gets or sets a URI that points to the literal example. - - - - - Gets or set extension data. - - - - - Models a `$ref` to an example. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Supports extension data for all types. - - - - - Models external documentation. - - - - - Gets or sets the description. - - - - - Gets the URL for the target documentation. - - - - - Gets or set extension data. - - - - - Creates a new - - The URL for the target documentation. - - - - Creates a new - - The URL for the target documentation. - - - - Provides extended JSON Schema formats. - - - - - Validates that a number is a valid 32-bit integer. - - - - - Validates that a number is a valid 64-bit integer. - - - - - Validates that a number is a valid single-precision floating point value. - - - - - Validates that a number is a valid double-precision floating point value. - - - - - Validates that a string is a password. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - Defines the source generated JSON serialization contract metadata for a given type. - - - - - The default associated with a default instance. - - - - - The source-generated options associated with this context. - - - - - - - - - - - - - - Models a header. - - - - - Gets or sets the description. - - - - - Gets or sets whether the header is required. - - - - - Gets or sets whether the header is deprecated. - - - - - Gets or sets whether the header can be present with an empty value. - - - - - Gets or sets how the header value will be serialized. - - - - - Gets or sets whether this will be exploded into multiple parameters. - - - - - Gets or sets whether the parameter value should allow reserved characters. - - - - - Gets or sets a schema for the content. - - - - - Gets or sets an example. - - - - - Gets or sets a collection of examples. - - - - - Gets or sets a collection of content. - - - - - Gets or set extension data. - - - - - Models a `$ref` to a header. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Indicates that the item is a reference to another object rather than being the object itself. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Resolves the reference. - - The document root. - Serializer options - A task. - - - - Models the license information. - - - - - Gets license name used for the API. - - - - - Gets or sets an SPDX license expression for the API. - - - - - Gets or sets URL to the license used for the API. - - - - - Gets or set extension data. - - - - - Creates a new - - The license name used for the API. - - - - Models a link object. - - - - - Gets or sets a relative or absolute URI reference to an OAS operation. - - - - - Gets or sets the name of the operation. - - - - - Gets or sets the parameter collection. - - - - - Gets or sets the request body for the target operation. - - - - - Gets or sets the description. - - - - - Gets or sets the server for the target operation. - - - - - Gets or set extension data. - - - - - Models a `$ref` to a link. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Models a media type object. - - - - - Gets or sets a schema for the meta type. - - - - - Gets or sets an example. - - - - - Gets or sets a collection of examples. - - - - - Gets or sets a collection of encodings. - - - - - Gets or set extension data. - - - - - Models an OAuth flow. - - - - - Gets the authorization URL. - - - - - Gets the token URL. - - - - - Gets or sets the refresh token URL. - - - - - Gets the scopes. - - - - - Gets or set extension data. - - - - - Creates a new - - The authorization URL - The token URL - The scopes - - - - Models the OAuth flow collection. - - - - - Gets or sets the implicit flow. - - - - - Gets or sets the password flow. - - - - - Gets or sets the client-credentials flow. - - - - - Gets or sets the authorization-code flow. - - - - - Gets or set extension data. - - - - - Models the OpenAPI document. - - - - - Gets the OpenAPI document version. - - - - - Gets the API information. - - - - - Gets or sets the default JSON Schema dialect. - - - - - Gets or sets the server collection. - - - - - Gets or sets the paths collection. - - - - - Gets or sets the webhooks collection. - - - - - Gets or sets the components collection. - - - - - Gets or sets the security requirements collection. - - - - - Gets or sets the tags. - - - - - Gets or sets external documentation. - - - - - Gets or set extension data. - - - - - Creates a new - - The OpenAPI version - The API information - - - - Initializes the document model. - - (optional) A schema registry. - (optional) Serializer options - Thrown if a reference cannot be resolved. - - - - Finds and retrieves an object within the document at a specified location. - - The type of object - The expected location - The object, if an object of that type exists at that location; otherwise null. - - - - Provides extensions on various OpenAPI types. - - - - - Validates a payload against the schema at the indicated location. - - The OpenAPI document. - The payload to validate. - The location within the document where the schema can be found. - (optional) The evaluation options. This should be the same options object used to initialize the OpenAPI document. - The evaluation options if the schema was found; otherwise null. - - - - Validates a payload against the schema at the indicated location. - - The OpenAPI document. - The payload to validate. - The location within the document where the schema can be found. - (optional) The evaluation options. This should be the same options object used to initialize the OpenAPI document. - The evaluation options if the schema was found; otherwise null. - - - - Validates a payload against the schema at the indicated location. - - The OpenAPI document. - The payload to validate. - The location within the document where the schema can be found. - (optional) The evaluation options. This should be the same options object used to initialize the OpenAPI document. - The evaluation options if the schema was found; otherwise null. - - - - Models the info object. - - - - - Gets the title. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets or sets the link to the terms of service. - - - - - Gets or sets the contact information. - - - - - Gets or sets the license information. - - - - - Gets or sets the API version. - - - - - Gets or set extension data. - - - - - Creates a new - - The title - The API version - - - - Models an operation. - - - - - Gets or sets the tags. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets or sets external documentation. - - - - - Gets or sets the operation ID. - - - - - Gets or sets the parameters. - - - - - Gets or sets the request body. - - - - - Gets or sets the response collection. - - - - - Gets or sets the callbacks collection. - - - - - Gets or sets whether the operation is deprecated. - - - - - Gets or sets the security requirements. - - - - - Gets or sets the server collection. - - - - - Gets or set extension data. - - - - - Models a parameter. - - - - - Gets the name. - - - - - Gets the parameter location. - - - - - Gets or sets the description. - - - - - Gets or sets whether the parameter is required. - - - - - Gets or sets whether the parameter is deprecated. - - - - - Gets or sets whether the parameter is allowed to be present with an empty value. - - - - - Gets or sets how the parameter value will be serialized. - - - - - Gets or sets whether this will be exploded into multiple parameters. - - - - - Gets or sets whether the parameter value should allow reserved characters. - - - - - Gets or sets a schema for the content. - - - - - Gets or sets an example. - - - - - Gets or sets a collection of examples. - - - - - Gets or sets a collection of content. - - - - - Gets or set extension data. - - - - - Creates a new - - The name - The parameter location - - - - Models a `$ref` to a parameter. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Defines the different parameter locations. - - - - - Indicates the location is unknown. - - - - - Indicates the parameter is in the query string. - - - - - Indicates the parameter is in a header. - - - - - Indicates the parameter is in the path. - - - - - Indicates the parameter is in a cookie. - - - - - Defines the different parameter styles. - - - - - Indicates the parameter type is unknown. - - - - - Path-style parameters defined by RFC6570 - - - - - Label style parameters defined by RFC6570 - - - - - Form style parameters defined by RFC6570 - - - - - Simple style parameters defined by RFC6570 - - - - - Space separated array or object values - - - - - Pipe separated array or object values - - - - - Provides a simple way of rendering nested objects using form parameters - - - - - Models a path collection. - - - - - Gets or set extension data. - - - - - Models an individual path. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets or sets the GET operation. - - - - - Gets or sets the PUT operation. - - - - - Gets or sets the POST operation. - - - - - Gets or sets the DELETE operation. - - - - - Gets or sets the OPTIONS operation. - - - - - Gets or sets the HEAD operation. - - - - - Gets or sets the PATCH operation. - - - - - Gets or sets the TRACE operation. - - - - - Gets or sets the collection of servers. - - - - - Gets or sets the collection of parameters. - - - - - Gets or set extension data. - - - - - Models a `$ref` to a path item. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Models a templated path URI. - - - - - Gets the segments of the path. - - - - - Parses a new path template from a string. - - The string source - A path template. - - - - Attempts to parse a new path template from a string. - - The string source - The path template if the parse succeeded, otherwise null - True if the parse succeeded, otherwise false. - - - Returns a string that represents the current object. - A string that represents the current object. - - - Indicates whether the current object is equal to another object of the same type. - An object to compare with this object. - - if the current object is equal to the parameter; otherwise, . - - - Indicates whether the current object is equal to another object of the same type. - An object to compare with this object. - - if the current object is equal to the parameter; otherwise, . - - - Determines whether the specified object is equal to the current object. - The object to compare with the current object. - - if the specified object is equal to the current object; otherwise, . - - - Serves as the default hash function. - A hash code for the current object. - - - - Implicitly converts a string to a path template via parsing. - - The string source. - - - - Allows customization of `$ref` resolutions. - - - - - Provides factory methods for creating references to objects in the components collection. - - - - - Creates a reference to a callback. - - The key that identifies the callback. - The reference. - - - - Creates a reference to a example. - - The key that identifies the example. - The reference. - - - - Creates a reference to a header. - - The key that identifies the header. - The reference. - - - - Creates a reference to a link. - - The key that identifies the link. - The reference. - - - - Creates a reference to a parameter. - - The key that identifies the parameter. - The reference. - - - - Creates a reference to a path item. - - The key that identifies the path item. - The reference. - - - - Creates a reference to a request body. - - The key that identifies the request body. - The reference. - - - - Creates a reference to a response. - - The key that identifies the response. - The reference. - - - - Creates a reference to a schema. - - The key that identifies the schema. - The reference. - - - - Creates a reference to a security scheme. - - The key that identifies the security scheme. - The reference. - - - - Gets or sets the `$ref` fetching function. - - - - - Defines a default basic fetching function that uses an - and supports YAML and JSON content. - - The resource URI - The JSON content as a `JsonNode` - - - - Thrown when a `$ref` cannot be resolved. - - - - - Creates a new - - The exception message. - - - - Models a request body. - - - - - Gets or sets the description. - - - - - Gets the content collection. - - - - - Gets or sets whether the request body is required. - - - - - Gets or set extension data. - - - - - Creates a new - - - - - - Models a `$ref` to a request body. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Models a response. - - - - - Gets the description. - - - - - Gets or sets the header collection. - - - - - Gets or sets the content collection. - - - - - Gets or sets the link collection. - - - - - Gets or set extension data. - - - - - Creates a new - - The description - - - - Models a `$ref` to a response. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Models a response collection. - - - - - Gets or sets the default response for the collection. - - - - - Gets or set extension data. - - - - - Models an OpenAPI runtime expression. - - - - - A `$url` runtime expression. - - - - - A `$method` runtime expression. - - - - - A `$statusCode` runtime expression. - - - - - Gets the expression type. - - - - - Gets the source type. - - - - - Gets the token. - - - - - Gets the name. - - - - - Gets the JSON Pointer. - - - - - Parses a runtime expression from a string. - - The string source - A runtime expression - Throw when the parse fails - - - Returns a string that represents the current object. - A string that represents the current object. - - - Indicates whether the current object is equal to another object of the same type. - An object to compare with this object. - - if the current object is equal to the parameter; otherwise, . - - - Indicates whether the current object is equal to another object of the same type. - An object to compare with this object. - - if the current object is equal to the parameter; otherwise, . - - - Determines whether the specified object is equal to the current object. - The object to compare with the current object. - - if the specified object is equal to the current object; otherwise, . - - - Serves as the default hash function. - A hash code for the current object. - - - - Defines the different runtime expression sources. - - - - - Indicates the expression source type is unknown. - - - - - Indicates the expression source is the header. - - - - - Indicates the expression source is the query string. - - - - - Indicates the expression source is the path. - - - - - Indicates the expression source is the body. - - - - - Defines the different types of runtime expressions. - - - - - Indicates the expression type is unknown. - - - - - Indicates a `$url` expression. - - - - - Indicates a `$method` expression. - - - - - Indicates a `$statusCode` expression. - - - - - Indicates a `$request` expression. - - - - - Indicates a `$response` expression. - - - - - Overrides the JSON Schema to support draft 4 boolean values. - - - - - The name of the keyword. - - - - - The boolean value, if it's a boolean. - - - - - The number value, if it's a number. - - - - - Creates a new . - - Whether the `minimum` value should be considered exclusive. - - - - Creates a new . - - The minimum value. - - - Builds a constraint object for a keyword. - The for the schema object that houses this keyword. - - The set of other s that have been processed prior to this one. - Will contain the constraints for keyword dependencies. - - The . - A constraint object. - - - - JSON converter for - - - - Reads and converts the JSON to type . - The reader. - The type to convert. - An object that specifies serialization options to use. - The converted value. - - - Writes a specified value as JSON. - The writer to write to. - The value to convert to JSON. - An object that specifies serialization options to use. - - - - Overrides the JSON Schema to support draft 4 boolean values. - - - - - The name of the keyword. - - - - - The boolean value, if it's a boolean. - - - - - The number value, if it's a number. - - - - - Creates a new . - - Whether the `minimum` value should be considered exclusive. - - - - Creates a new . - - The minimum value. - - - Builds a constraint object for a keyword. - The for the schema object that houses this keyword. - - The set of other s that have been processed prior to this one. - Will contain the constraints for keyword dependencies. - - The . - A constraint object. - - - - JSON converter for - - - - Reads and converts the JSON to type . - The reader. - The type to convert. - An object that specifies serialization options to use. - The converted value. - - - Writes a specified value as JSON. - The writer to write to. - The value to convert to JSON. - An object that specifies serialization options to use. - - - - Represents the JSON Schema draft 4 `id` keyword. - - - - - The name of the keyword. - - - - - The ID. - - - - - Creates a new . - - The ID. - - - Builds a constraint object for a keyword. - The for the schema object that houses this keyword. - - The set of other s that have been processed prior to this one. - Will contain the constraints for keyword dependencies. - - The . - A constraint object. - - - - JSON converter for - - - - Reads and converts the JSON to type . - The reader. - The type to convert. - An object that specifies serialization options to use. - The converted value. - - - Writes a specified value as JSON. - The writer to write to. - The value to convert to JSON. - An object that specifies serialization options to use. - - - - Provides additional functionality for JSON Schema draft 4 support. - - - - - Defines a JSON Schema draft 4 spec version. - - - - - Defines the OpenAPI / JSON Schema draft 4 `file` type. - - - - - Defines the JSON Schema draft 4 meta-schema URI. - - - - - Defines the JSON Schema draft 4 meta-schema. - - - - - Enables support for OpenAPI v3.0 and JSON Schema draft 4. - - - - - Overrides the JSON Schema to support draft 4. - - - - - The name of the keyword. - - - - - The ID. - - - - - Creates a new . - - The instance type that is allowed. - - - Builds a constraint object for a keyword. - The for the schema object that houses this keyword. - - The set of other s that have been processed prior to this one. - Will contain the constraints for keyword dependencies. - - The . - A constraint object. - - - - JSON converter for - - - - Reads and converts the JSON to type . - The reader. - The type to convert. - An object that specifies serialization options to use. - The converted value. - - - Writes a specified value as JSON. - The writer to write to. - The value to convert to JSON. - An object that specifies serialization options to use. - - - - Provides extension methods for explicitly building draft 4 schemas. - - - - - Adds the draft 4 `exclusiveMaximum` override. - - The builder - The value - The builder - - - - Adds the draft 4 `exclusiveMinimum` override. - - The builder - The value - The builder - - - - Adds the draft 4 `id` override. - - The builder - The id - The builder - - - - Adds the draft 4 `id` override. - - The builder - The id - The builder - - - - Adds the draft 4 `type` override. - - The builder - The type - The builder - - - - Adds the `nullable` keyword. - - The builder - The value - The builder - - - - Provides the OpenAPI `nullable` keyword. - - - - - The name of the keyword. - - - - - The ID. - - - - - Creates a new . - - Whether the `minimum` value should be considered exclusive. - - - Builds a constraint object for a keyword. - The for the schema object that houses this keyword. - - The set of other s that have been processed prior to this one. - Will contain the constraints for keyword dependencies. - - The . - A constraint object. - - - - JSON converter for - - - - Reads and converts the JSON to type . - The reader. - The type to convert. - An object that specifies serialization options to use. - The converted value. - - - Writes a specified value as JSON. - The writer to write to. - The value to convert to JSON. - An object that specifies serialization options to use. - - - - Models a security requirement. - - - - - Models a security scheme. - - - - - Gets the type of security scheme. - - - - - Gets or sets the description. - - - - - Gets or sets the name. - - - - - Gets or sets the location of the API key. - - - - - Gets or sets the scheme. - - - - - Gets or sets the bearer token format. - - - - - Gets or sets the collection of OAuth flows. - - - - - Gets the OpenID Connect URL. - - - - - Gets or set extension data. - - - - - Creates a new - - The security scheme type - - - - Models a `$ref` to a security scheme. - - - - - The URI for the reference. - - - - - Gets or sets the summary. - - - - - Gets or sets the description. - - - - - Gets whether the reference has been resolved. - - - - - Creates a new - - The reference URI - - - - Creates a new - - The reference URI - - - - Defines the different security scheme locations. - - - - - Indicates the location is unknown. - - - - - Indicates the API key is located in the query. - - - - - Indicates the API key is located in a header. - - - - - Indicates the API key is located in a cookie. - - - - - Models a server. - - - - - Gets the URL of the server. - - - - - Gets or sets the description. - - - - - Gets or sets the variable map. - - - - - Gets or set extension data. - - - - - Creates a new - - The server URL - - - - Models a server variable. - - - - - Gets or sets an enumeration of string values to be used if the substitution options are from a limited set. - - - - - Gets the default value to use for substitution. - - - - - Gets or sets the description. - - - - - Gets or set extension data. - - - - - Creates a new - - The default value - - - - Models a tag. - - - - - Gets the tag name. - - - - - Gets or sets the tag description. - - - - - Gets or sets external documentation. - - - - - Gets or set extension data. - - - - - Creates a new - - The tag name - - - - Creates a new from a . - - The `JsonNode`. - The model. - Thrown when the JSON does not accurately represent the model. - - - diff --git a/Graeae.Models/Header.cs b/Graeae.Models/Header.cs index a4d471b..331a11e 100644 --- a/Graeae.Models/Header.cs +++ b/Graeae.Models/Header.cs @@ -164,7 +164,7 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) // TODO: consider some other kind of value being buried in a schema throw new NotImplementedException(); case "example": - return Example?.GetFromNode(keys[1..]); + return Example?.GetFromNode(keys.Slice(1)); case "examples": if (keys.Length == 1) return null; keysConsumed++; @@ -178,7 +178,7 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) } return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys.Slice(keysConsumed)) : ExtensionData?.Resolve(keys); } diff --git a/Graeae.Models/Link.cs b/Graeae.Models/Link.cs index 592b96b..c9df08a 100644 --- a/Graeae.Models/Link.cs +++ b/Graeae.Models/Link.cs @@ -119,7 +119,7 @@ private protected void Import(JsonObject obj) if (keys[0] == "server") { if (keys.Length == 1) return Server; - return Server?.Resolve(keys[1..]); + return Server?.Resolve(keys.Slice(1)); } return ExtensionData?.Resolve(keys); diff --git a/Graeae.Models/MediaType.cs b/Graeae.Models/MediaType.cs index 392de33..45b1243 100644 --- a/Graeae.Models/MediaType.cs +++ b/Graeae.Models/MediaType.cs @@ -89,7 +89,7 @@ internal static MediaType FromNode(JsonNode? node, JsonSerializerOptions? option // TODO: consider some other kind of value being buried in a schema throw new NotImplementedException(); case "example": - return Example?.GetFromNode(keys[1..]); + return Example?.GetFromNode(keys.Slice(1)); case "examples": if (keys.Length == 1) return null; keysConsumed++; @@ -103,7 +103,7 @@ internal static MediaType FromNode(JsonNode? node, JsonSerializerOptions? option } return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys.Slice(keysConsumed)) : ExtensionData?.Resolve(keys); } diff --git a/Graeae.Models/OAuthFlow.cs b/Graeae.Models/OAuthFlow.cs index 9fd2692..256a9d9 100644 --- a/Graeae.Models/OAuthFlow.cs +++ b/Graeae.Models/OAuthFlow.cs @@ -82,9 +82,9 @@ internal static OAuthFlow FromNode(JsonNode? node) }; var scopes = new JsonObject(); - foreach (var (key, value) in flow.Scopes) + foreach (var kvp in flow.Scopes) { - scopes.Add(key, value); + scopes.Add(kvp.Key, kvp.Value); } obj.Add("scopes", scopes); diff --git a/Graeae.Models/OAuthFlowCollection.cs b/Graeae.Models/OAuthFlowCollection.cs index 2e14e48..e6597e1 100644 --- a/Graeae.Models/OAuthFlowCollection.cs +++ b/Graeae.Models/OAuthFlowCollection.cs @@ -96,7 +96,7 @@ internal static OAuthFlowCollection FromNode(JsonNode? node) } return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys.Slice(keysConsumed)) : ExtensionData?.Resolve(keys); } } diff --git a/Graeae.Models/OpenApiDocument.cs b/Graeae.Models/OpenApiDocument.cs index 2316109..681faa7 100644 --- a/Graeae.Models/OpenApiDocument.cs +++ b/Graeae.Models/OpenApiDocument.cs @@ -84,7 +84,7 @@ public class OpenApiDocument : IBaseDocument Uri IBaseDocument.BaseUri { get; } = GenerateBaseUri(); - private static Uri GenerateBaseUri() => new($"openapi:stj.openapi.models:{Guid.NewGuid().ToString("N")[..10]}"); + private static Uri GenerateBaseUri() => new($"graeae:models:{Guid.NewGuid().ToString("N").AsSpan(0, 10).ToString()}"); static OpenApiDocument() { @@ -276,7 +276,7 @@ private async Task TryResolveRefs(JsonSerializerOptions? options) } return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys.Slice(keysConsumed)) : ExtensionData?.Resolve(keys); } } diff --git a/Graeae.Models/OpenApiInfo.cs b/Graeae.Models/OpenApiInfo.cs index b58709a..1b25ec1 100644 --- a/Graeae.Models/OpenApiInfo.cs +++ b/Graeae.Models/OpenApiInfo.cs @@ -111,20 +111,15 @@ internal static OpenApiInfo FromNode(JsonNode? node) { if (keys.Length == 0) return this; - int keysConsumed = 1; - IRefTargetContainer? target = null; - switch (keys[0]) + IRefTargetContainer? target = keys[0] switch { - case "contact": - target = Contact; - break; - case "license": - target = License; - break; - } + "contact" => Contact, + "license" => License, + _ => null + }; return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys[1..]) : ExtensionData?.Resolve(keys); } } diff --git a/Graeae.Models/Operation.cs b/Graeae.Models/Operation.cs index e5cdaff..a18e4ba 100644 --- a/Graeae.Models/Operation.cs +++ b/Graeae.Models/Operation.cs @@ -165,7 +165,7 @@ internal static Operation FromNode(JsonNode? node, JsonSerializerOptions? option } return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys.Slice(keysConsumed)) : ExtensionData?.Resolve(keys); } diff --git a/Graeae.Models/Parameter.cs b/Graeae.Models/Parameter.cs index f14e916..66defda 100644 --- a/Graeae.Models/Parameter.cs +++ b/Graeae.Models/Parameter.cs @@ -191,7 +191,7 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) // TODO: consider some other kind of value being buried in a schema throw new NotImplementedException(); case "example": - return Example?.GetFromNode(keys[1..]); + return Example?.GetFromNode(keys.Slice(1)); case "examples": if (keys.Length == 1) return null; keysConsumed++; @@ -205,7 +205,7 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) } return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys.Slice(keysConsumed)) : ExtensionData?.Resolve(keys); } diff --git a/Graeae.Models/ParsingHelper.cs b/Graeae.Models/ParsingHelper.cs index 5547f71..a9437bc 100644 --- a/Graeae.Models/ParsingHelper.cs +++ b/Graeae.Models/ParsingHelper.cs @@ -4,7 +4,7 @@ internal static class ParsingHelper { public static string Expect(this string source, ref int i, params string[] options) { - var text = source[i..]; + var text = source.Substring(i); foreach (var option in options) { if (text.StartsWith(option)) diff --git a/Graeae.Models/PathCollection.cs b/Graeae.Models/PathCollection.cs index 225ac76..ad427d9 100644 --- a/Graeae.Models/PathCollection.cs +++ b/Graeae.Models/PathCollection.cs @@ -26,13 +26,13 @@ internal static PathCollection FromNode(JsonNode? node, JsonSerializerOptions? o ExtensionData = ExtensionData.FromNode(obj) }; - foreach (var (key, value) in obj) + foreach (var kvp in obj) { - if (key.StartsWith("x-")) continue; - if (!PathTemplate.TryParse(key, out var template)) - throw new JsonException($"`{key}` is not a valid path template"); + if (kvp.Key.StartsWith("x-")) continue; + if (!PathTemplate.TryParse(kvp.Key, out var template)) + throw new JsonException($"`{kvp.Key}` is not a valid path template"); - collection.Add(template, PathItem.FromNode(value, options)); + collection.Add(template, PathItem.FromNode(kvp.Value, options)); } // Validating extra keys is done in the loop. @@ -46,9 +46,9 @@ internal static PathCollection FromNode(JsonNode? node, JsonSerializerOptions? o var obj = new JsonObject(); - foreach (var (key, value) in paths) + foreach (var kvp in paths) { - obj.Add(key.ToString(), PathItem.ToNode(value, options)); + obj.Add(kvp.Key.ToString(), PathItem.ToNode(kvp.Value, options)); } obj.AddExtensions(paths.ExtensionData); @@ -60,7 +60,7 @@ internal static PathCollection FromNode(JsonNode? node, JsonSerializerOptions? o { if (keys.Length == 0) return null; - return this.GetFromMap(keys[0])?.Resolve(keys[1..]) ?? + return this.GetFromMap(keys[0])?.Resolve(keys.Slice(1)) ?? ExtensionData?.Resolve(keys); } diff --git a/Graeae.Models/PathItem.cs b/Graeae.Models/PathItem.cs index fb4d741..5994ec7 100644 --- a/Graeae.Models/PathItem.cs +++ b/Graeae.Models/PathItem.cs @@ -204,7 +204,7 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) } return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys.Slice(keysConsumed)) : ExtensionData?.Resolve(keys); } diff --git a/Graeae.Models/PathTemplate.cs b/Graeae.Models/PathTemplate.cs index a9bb194..6ed73fb 100644 --- a/Graeae.Models/PathTemplate.cs +++ b/Graeae.Models/PathTemplate.cs @@ -68,7 +68,7 @@ public bool Equals(PathTemplate? other) if (Segments.Length != other.Segments.Length) return false; - var zipped = Segments.Zip(other.Segments); + var zipped = Segments.Zip(other.Segments, (x,y) => (First: x, Second: y)); return zipped.All(x => x.First == x.Second || TemplatedSegmentPattern.IsMatch(x.First) && TemplatedSegmentPattern.IsMatch(x.Second)); diff --git a/Graeae.Models/RequestBody.cs b/Graeae.Models/RequestBody.cs index 9bfb513..6df861d 100644 --- a/Graeae.Models/RequestBody.cs +++ b/Graeae.Models/RequestBody.cs @@ -111,7 +111,7 @@ private protected void Import(JsonObject obj) { if (keys.Length == 1) return null; var target = Content.GetFromMap(keys[1]); - return target?.Resolve(keys[2..]); + return target?.Resolve(keys.Slice(2)); } return ExtensionData?.Resolve(keys); diff --git a/Graeae.Models/Response.cs b/Graeae.Models/Response.cs index 8b12efd..3e5000f 100644 --- a/Graeae.Models/Response.cs +++ b/Graeae.Models/Response.cs @@ -136,7 +136,7 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) } return target != null - ? target.Resolve(keys[keysConsumed..]) + ? target.Resolve(keys.Slice(keysConsumed)) : ExtensionData?.Resolve(keys); } diff --git a/Graeae.Models/ResponseCollection.cs b/Graeae.Models/ResponseCollection.cs index badb1d1..fbf25c7 100644 --- a/Graeae.Models/ResponseCollection.cs +++ b/Graeae.Models/ResponseCollection.cs @@ -32,16 +32,16 @@ internal static ResponseCollection FromNode(JsonNode? node, JsonSerializerOption ExtensionData = ExtensionData.FromNode(obj) }; - foreach (var (key, value) in obj) + foreach (var kvp in obj) { - if (key == "default") continue; - if (key.StartsWith("x-")) continue; - if (!short.TryParse(key, out var code)) - throw new JsonException($"`{key}` is not a valid status code"); + if (kvp.Key == "default") continue; + if (kvp.Key.StartsWith("x-")) continue; + if (!short.TryParse(kvp.Key, out var code)) + throw new JsonException($"`{kvp.Key}` is not a valid status code"); if (Enum.GetName(typeof(HttpStatusCode), code) == null) - throw new JsonException($"`{key}` is not a known status code"); + throw new JsonException($"`{kvp.Key}` is not a known status code"); - collection.Add((HttpStatusCode)code, Response.FromNode(value, options)); + collection.Add((HttpStatusCode)code, Response.FromNode(kvp.Value, options)); } // Validating extra keys is done in the loop. @@ -55,9 +55,9 @@ internal static ResponseCollection FromNode(JsonNode? node, JsonSerializerOption var obj = new JsonObject(); - foreach (var (key, value) in responses) + foreach (var kvp in responses) { - obj.Add(((int)key).ToString(), Response.ToNode(value, options)); + obj.Add(((int)kvp.Key).ToString(), Response.ToNode(kvp.Value, options)); } obj.MaybeAdd("default", Response.ToNode(responses.Default, options)); @@ -72,7 +72,7 @@ internal static ResponseCollection FromNode(JsonNode? node, JsonSerializerOption if (keys.Length == 0) return null; var first = keys[0]; - return this.FirstOrDefault(x => ((int)x.Key).ToString() == first).Value?.Resolve(keys[1..]) ?? + return this.FirstOrDefault(x => ((int)x.Key).ToString() == first).Value?.Resolve(keys.Slice(1)) ?? ExtensionData?.Resolve(keys); } diff --git a/Graeae.Models/RuntimeExpression.cs b/Graeae.Models/RuntimeExpression.cs index cb4838b..831e463 100644 --- a/Graeae.Models/RuntimeExpression.cs +++ b/Graeae.Models/RuntimeExpression.cs @@ -122,24 +122,24 @@ public static RuntimeExpression Parse(string source) { j++; } - expr.Token = source[i..j]; + expr.Token = source.Substring(i, j-i); break; case "query": expr.SourceType = RuntimeExpressionSourceType.Query; source.Expect(ref i, "."); - expr.Name = source[i..]; + expr.Name = source.Substring(i); break; case "path": expr.SourceType = RuntimeExpressionSourceType.Path; source.Expect(ref i, "."); - expr.Name = source[i..]; + expr.Name = source.Substring(i); break; case "body": expr.SourceType = RuntimeExpressionSourceType.Body; source.Expect(ref i, "#"); if (i < source.Length) { - if (JsonPointer.TryParse(source[i..], out var jp)) + if (JsonPointer.TryParse(source.Substring(i), out var jp)) expr.JsonPointer = jp; else throw new JsonException("Text after `#` must be a valid JSON Pointer"); diff --git a/Graeae.Models/SecurityRequirement.cs b/Graeae.Models/SecurityRequirement.cs index d4d5184..56b5f3e 100644 --- a/Graeae.Models/SecurityRequirement.cs +++ b/Graeae.Models/SecurityRequirement.cs @@ -18,13 +18,13 @@ internal static SecurityRequirement FromNode(JsonNode? node) var callback = new SecurityRequirement(); - foreach (var (key, value) in obj) + foreach (var kvp in obj) { - if (value is not JsonArray array) + if (kvp.Value is not JsonArray array) throw new JsonException("security requirements must be string arrays"); - callback.Add(key, array.Select(x => x is JsonValue v && v.TryGetValue(out string? s) ? s : throw new JsonException("security requirement values must be strings"))); + callback.Add(kvp.Key, array.Select(x => x is JsonValue v && v.TryGetValue(out string? s) ? s : throw new JsonException("security requirement values must be strings"))); } // Validating extra keys is done in the loop. @@ -38,9 +38,9 @@ internal static SecurityRequirement FromNode(JsonNode? node) var obj = new JsonObject(); - foreach (var (key, value) in requirement) + foreach (var kvp in requirement) { - obj.Add(key, value.Select(x => (JsonNode?)x).ToJsonArray()); + obj.Add(kvp.Key, kvp.Value.Select(x => (JsonNode?)x).ToJsonArray()); } return obj; diff --git a/Graeae.Models/SecurityScheme.cs b/Graeae.Models/SecurityScheme.cs index 56f1a94..cc89d92 100644 --- a/Graeae.Models/SecurityScheme.cs +++ b/Graeae.Models/SecurityScheme.cs @@ -144,7 +144,7 @@ private protected void Import(JsonObject obj, JsonSerializerOptions? options) if (keys[0] == "flows") { if (keys.Length == 1) return Flows; - return Flows?.Resolve(keys[1..]); + return Flows?.Resolve(keys.Slice(1)); } return ExtensionData?.Resolve(keys); diff --git a/Graeae.Models/SerializationExtensions.cs b/Graeae.Models/SerializationExtensions.cs index d412682..380a07c 100644 --- a/Graeae.Models/SerializationExtensions.cs +++ b/Graeae.Models/SerializationExtensions.cs @@ -54,10 +54,10 @@ public static Dictionary ExpectMap(this JsonObject obj, string pro var deserialized = new Dictionary(); - foreach (var (key, value) in map) + foreach (var kvp in map) { - var item = factory(value); - deserialized.Add(key, item); + var item = factory(kvp.Value); + deserialized.Add(kvp.Key, item); } return deserialized; @@ -71,10 +71,10 @@ public static Dictionary ExpectMap(this JsonObject obj, string pro var deserialized = new Dictionary(); - foreach (var (key, value) in map) + foreach (var kvp in map) { - var item = factory(value); - deserialized.Add(key, item); + var item = factory(kvp.Value); + deserialized.Add(kvp.Key, item); } return deserialized; @@ -179,9 +179,9 @@ public static void AddExtensions(this JsonObject obj, ExtensionData? extensionDa { if (extensionData == null) return; - foreach (var (key, value) in extensionData) + foreach (var kvp in extensionData) { - obj.Add(key, value?.DeepClone()); + obj.Add(kvp.Key, kvp.Value?.DeepClone()); } } @@ -198,10 +198,10 @@ public static void MaybeAddMap(this JsonObject obj, string propertyName, Dict // We do this manually here because .ToDictionary() allocates an intermediate dictionary var newObj = new JsonObject(); - foreach (var (key, value) in values) + foreach (var kvp in values) { - var node = convert(value); - newObj.Add(key, node); + var node = convert(kvp.Value); + newObj.Add(kvp.Key, node); } obj.Add(propertyName, newObj); diff --git a/Graeae.Models/Server.cs b/Graeae.Models/Server.cs index f269c72..e12dd21 100644 --- a/Graeae.Models/Server.cs +++ b/Graeae.Models/Server.cs @@ -83,7 +83,7 @@ internal static Server FromNode(JsonNode? node) if (keys[0] == "variables") { if (keys.Length == 1) return null; - return Variables.GetFromMap(keys[1])?.Resolve(keys[2..]); + return Variables.GetFromMap(keys[1])?.Resolve(keys.Slice(2)); } return ExtensionData?.Resolve(keys); diff --git a/Graeae.Models/Tag.cs b/Graeae.Models/Tag.cs index f3ad2e1..5dfaeab 100644 --- a/Graeae.Models/Tag.cs +++ b/Graeae.Models/Tag.cs @@ -89,7 +89,7 @@ internal static Tag FromNode(JsonNode? node) if (keys[0] == "externalDocs") { if (keys.Length == 1) return ExternalDocs; - return ExternalDocs?.Resolve(keys[1..]); + return ExternalDocs?.Resolve(keys.Slice(1)); } return ExtensionData?.Resolve(keys); diff --git a/Graeae.sln b/Graeae.sln index 9b421b7..7fd7184 100644 --- a/Graeae.sln +++ b/Graeae.sln @@ -7,6 +7,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Graeae.Models", "Graeae.Mod EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Graeae.Models.Tests", "Graeae.Models.Tests\Graeae.Models.Tests.csproj", "{943DABF3-96AB-47AE-88C6-9FCEBCA82920}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Graeae.AspNet", "Graeae.AspNet\Graeae.AspNet.csproj", "{B24340A3-0F0B-4E1A-A998-0C0C49718E20}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Graeae.AspNet.Tests.Host", "Graeae.AspNet.Tests.Host\Graeae.AspNet.Tests.Host.csproj", "{1C13A90F-1A7E-4FE4-BA9F-95A35A3F97A3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Graeae.AspNet.Analyzer", "Graeae.AspNet.Analyzer\Graeae.AspNet.Analyzer.csproj", "{BDED533C-D538-4E96-B37B-A4C65FA4D504}" + ProjectSection(ProjectDependencies) = postProject + {B24340A3-0F0B-4E1A-A998-0C0C49718E20} = {B24340A3-0F0B-4E1A-A998-0C0C49718E20} + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,6 +35,24 @@ Global {943DABF3-96AB-47AE-88C6-9FCEBCA82920}.Localization|Any CPU.Build.0 = Release|Any CPU {943DABF3-96AB-47AE-88C6-9FCEBCA82920}.Release|Any CPU.ActiveCfg = Release|Any CPU {943DABF3-96AB-47AE-88C6-9FCEBCA82920}.Release|Any CPU.Build.0 = Release|Any CPU + {B24340A3-0F0B-4E1A-A998-0C0C49718E20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B24340A3-0F0B-4E1A-A998-0C0C49718E20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B24340A3-0F0B-4E1A-A998-0C0C49718E20}.Localization|Any CPU.ActiveCfg = Debug|Any CPU + {B24340A3-0F0B-4E1A-A998-0C0C49718E20}.Localization|Any CPU.Build.0 = Debug|Any CPU + {B24340A3-0F0B-4E1A-A998-0C0C49718E20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B24340A3-0F0B-4E1A-A998-0C0C49718E20}.Release|Any CPU.Build.0 = Release|Any CPU + {1C13A90F-1A7E-4FE4-BA9F-95A35A3F97A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C13A90F-1A7E-4FE4-BA9F-95A35A3F97A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C13A90F-1A7E-4FE4-BA9F-95A35A3F97A3}.Localization|Any CPU.ActiveCfg = Debug|Any CPU + {1C13A90F-1A7E-4FE4-BA9F-95A35A3F97A3}.Localization|Any CPU.Build.0 = Debug|Any CPU + {1C13A90F-1A7E-4FE4-BA9F-95A35A3F97A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C13A90F-1A7E-4FE4-BA9F-95A35A3F97A3}.Release|Any CPU.Build.0 = Release|Any CPU + {BDED533C-D538-4E96-B37B-A4C65FA4D504}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDED533C-D538-4E96-B37B-A4C65FA4D504}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDED533C-D538-4E96-B37B-A4C65FA4D504}.Localization|Any CPU.ActiveCfg = Debug|Any CPU + {BDED533C-D538-4E96-B37B-A4C65FA4D504}.Localization|Any CPU.Build.0 = Debug|Any CPU + {BDED533C-D538-4E96-B37B-A4C65FA4D504}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BDED533C-D538-4E96-B37B-A4C65FA4D504}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE