diff --git a/src/Microsoft.OpenApi.Readers/ParsingContext.cs b/src/Microsoft.OpenApi.Readers/ParsingContext.cs index 6c4dece2f..ac1e2a497 100644 --- a/src/Microsoft.OpenApi.Readers/ParsingContext.cs +++ b/src/Microsoft.OpenApi.Readers/ParsingContext.cs @@ -63,12 +63,14 @@ internal OpenApiDocument Parse(YamlDocument yamlDocument) VersionService = new OpenApiV2VersionService(Diagnostic); doc = VersionService.LoadDocument(RootNode); this.Diagnostic.SpecificationVersion = OpenApiSpecVersion.OpenApi2_0; + ValidateRequiredFields(doc, version); break; - case string version when version.StartsWith("3.0"): + case string version when version.StartsWith("3.0") || version.StartsWith("3.1"): VersionService = new OpenApiV3VersionService(Diagnostic); doc = VersionService.LoadDocument(RootNode); this.Diagnostic.SpecificationVersion = OpenApiSpecVersion.OpenApi3_0; + ValidateRequiredFields(doc, version); break; default: @@ -244,5 +246,21 @@ public void PopLoop(string loopid) } } + private void ValidateRequiredFields(OpenApiDocument doc, string version) + { + if ((version == "2.0" || version.StartsWith("3.0")) && (doc.Paths == null || !doc.Paths.Any())) + { + // paths is a required field in OpenAPI 3.0 but optional in 3.1 + RootNode.Context.Diagnostic.Errors.Add(new OpenApiError("", $"Paths is a REQUIRED field at {RootNode.Context.GetLocation()}")); + } + else if (version.StartsWith("3.1")) + { + if ((doc.Paths == null || !doc.Paths.Any()) && (doc.Webhooks == null || !doc.Webhooks.Any())) + { + RootNode.Context.Diagnostic.Errors.Add(new OpenApiError( + "", $"The document MUST contain either a Paths or Webhooks field at {RootNode.Context.GetLocation()}")); + } + } + } } } diff --git a/src/Microsoft.OpenApi.Readers/V3/OpenApiDocumentDeserializer.cs b/src/Microsoft.OpenApi.Readers/V3/OpenApiDocumentDeserializer.cs index df1434cd9..4857251da 100644 --- a/src/Microsoft.OpenApi.Readers/V3/OpenApiDocumentDeserializer.cs +++ b/src/Microsoft.OpenApi.Readers/V3/OpenApiDocumentDeserializer.cs @@ -1,10 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System.Collections.Generic; -using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Extensions; -using Microsoft.OpenApi.Interfaces; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Readers.ParseNodes; @@ -26,6 +23,7 @@ internal static partial class OpenApiV3Deserializer {"info", (o, n) => o.Info = LoadInfo(n)}, {"servers", (o, n) => o.Servers = n.CreateList(LoadServer)}, {"paths", (o, n) => o.Paths = LoadPaths(n)}, + {"webhooks", (o, n) => o.Webhooks = LoadPaths(n)}, {"components", (o, n) => o.Components = LoadComponents(n)}, {"tags", (o, n) => {o.Tags = n.CreateList(LoadTag); foreach (var tag in o.Tags) @@ -50,7 +48,7 @@ internal static partial class OpenApiV3Deserializer public static OpenApiDocument LoadOpenApi(RootNode rootNode) { var openApidoc = new OpenApiDocument(); - + var openApiNode = rootNode.GetMap(); ParseMap(openApiNode, openApidoc, _openApiFixedFields, _openApiPatternFields); diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 553844764..40c082915 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -20,6 +20,11 @@ public static class OpenApiConstants /// public const string Info = "info"; + /// + /// Field: Webhooks + /// + public const string Webhooks = "webhooks"; + /// /// Field: Title /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index 5177e4f45..9544550b5 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs @@ -39,6 +39,13 @@ public class OpenApiDocument : IOpenApiSerializable, IOpenApiExtensible /// public OpenApiPaths Paths { get; set; } + /// + /// The incoming webhooks that MAY be received as part of this API and that the API consumer MAY choose to implement. + /// A map of requests initiated other than by an API call, for example by an out of band registration. + /// The key name is a unique string to refer to each webhook, while the (optionally referenced) Path Item Object describes a request that may be initiated by the API provider and the expected responses + /// + public IDictionary Webhooks { get; set; } = new Dictionary(); + /// /// An element to hold various schemas for the specification. /// @@ -84,6 +91,7 @@ public OpenApiDocument(OpenApiDocument document) Info = document?.Info != null ? new(document?.Info) : null; Servers = document?.Servers != null ? new List(document.Servers) : null; Paths = document?.Paths != null ? new(document?.Paths) : null; + Webhooks = document?.Webhooks != null ? new Dictionary(document.Webhooks) : null; Components = document?.Components != null ? new(document?.Components) : null; SecurityRequirements = document?.SecurityRequirements != null ? new List(document.SecurityRequirements) : null; Tags = document?.Tags != null ? new List(document.Tags) : null; @@ -115,6 +123,24 @@ public void SerializeAsV3(IOpenApiWriter writer) // paths writer.WriteRequiredObject(OpenApiConstants.Paths, Paths, (w, p) => p.SerializeAsV3(w)); + // webhooks + writer.WriteOptionalMap( + OpenApiConstants.Webhooks, + Webhooks, + (w, key, component) => + { + if (component.Reference != null && + component.Reference.Type == ReferenceType.PathItem && + component.Reference.Id == key) + { + component.SerializeAsV3WithoutReference(w); + } + else + { + component.SerializeAsV3(w); + } + }); + // components writer.WriteOptionalObject(OpenApiConstants.Components, Components, (w, c) => c.SerializeAsV3(w)); diff --git a/src/Microsoft.OpenApi/Models/ReferenceType.cs b/src/Microsoft.OpenApi/Models/ReferenceType.cs index 6ac0c9ed2..b86f3d171 100644 --- a/src/Microsoft.OpenApi/Models/ReferenceType.cs +++ b/src/Microsoft.OpenApi/Models/ReferenceType.cs @@ -58,6 +58,11 @@ public enum ReferenceType /// /// Tags item. /// - [Display("tags")] Tag + [Display("tags")] Tag, + + /// + /// Path item. + /// + [Display("pathItem")] PathItem, } } diff --git a/src/Microsoft.OpenApi/Services/OpenApiVisitorBase.cs b/src/Microsoft.OpenApi/Services/OpenApiVisitorBase.cs index c9679381a..85a90a0ef 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiVisitorBase.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiVisitorBase.cs @@ -99,6 +99,13 @@ public virtual void Visit(OpenApiPaths paths) { } + /// + /// Visits Webhooks> + /// + public virtual void Visit(IDictionary webhooks) + { + } + /// /// Visits /// diff --git a/src/Microsoft.OpenApi/Services/OpenApiWalker.cs b/src/Microsoft.OpenApi/Services/OpenApiWalker.cs index 78ca5e61b..42afba695 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWalker.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWalker.cs @@ -46,6 +46,7 @@ public void Walk(OpenApiDocument doc) Walk(OpenApiConstants.Info, () => Walk(doc.Info)); Walk(OpenApiConstants.Servers, () => Walk(doc.Servers)); Walk(OpenApiConstants.Paths, () => Walk(doc.Paths)); + Walk(OpenApiConstants.Webhooks, () => Walk(doc.Webhooks)); Walk(OpenApiConstants.Components, () => Walk(doc.Components)); Walk(OpenApiConstants.Security, () => Walk(doc.SecurityRequirements)); Walk(OpenApiConstants.ExternalDocs, () => Walk(doc.ExternalDocs)); @@ -221,6 +222,28 @@ internal void Walk(OpenApiPaths paths) } } + /// + /// Visits Webhooks and child objects + /// + internal void Walk(IDictionary webhooks) + { + if (webhooks == null) + { + return; + } + + _visitor.Visit(webhooks); + + // Visit Webhooks + if (webhooks != null) + { + foreach (var pathItem in webhooks) + { + Walk(pathItem.Key, () => Walk(pathItem.Value)); + } + } + } + /// /// Visits list of and child objects /// diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs index e5193b4c2..7f468f59a 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs @@ -28,15 +28,6 @@ public static class OpenApiDocumentRules String.Format(SRResource.Validation_FieldIsRequired, "info", "document")); } context.Exit(); - - // paths - context.Enter("paths"); - if (item.Paths == null) - { - context.CreateError(nameof(OpenApiDocumentFieldIsMissing), - String.Format(SRResource.Validation_FieldIsRequired, "paths", "document")); - } - context.Exit(); }); } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj b/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj index 1579f85e5..35b0595d9 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj +++ b/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj @@ -137,6 +137,9 @@ Never + + Never + Never diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs index 6fbb7065a..85922f993 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs @@ -1327,5 +1327,213 @@ public void HeaderParameterShouldAllowExample() }); } } + + [Fact] + public void ParseDocumentWithWebhooksShouldSucceed() + { + // Arrange and Act + using var stream = Resources.GetStream(Path.Combine(SampleFolderPath, "documentWithWebhooks.yaml")); + var actual = new OpenApiStreamReader().Read(stream, out var diagnostic); + + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["pet"] = new OpenApiSchema + { + Type = "object", + Required = new HashSet + { + "id", + "name" + }, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema + { + Type = "integer", + Format = "int64" + }, + ["name"] = new OpenApiSchema + { + Type = "string" + }, + ["tag"] = new OpenApiSchema + { + Type = "string" + }, + }, + Reference = new OpenApiReference + { + Type = ReferenceType.Schema, + Id = "pet", + HostDocument = actual + } + }, + ["newPet"] = new OpenApiSchema + { + Type = "object", + Required = new HashSet + { + "name" + }, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema + { + Type = "integer", + Format = "int64" + }, + ["name"] = new OpenApiSchema + { + Type = "string" + }, + ["tag"] = new OpenApiSchema + { + Type = "string" + }, + }, + Reference = new OpenApiReference + { + Type = ReferenceType.Schema, + Id = "newPet", + HostDocument = actual + } + } + } + }; + + // Create a clone of the schema to avoid modifying things in components. + var petSchema = Clone(components.Schemas["pet"]); + + petSchema.Reference = new OpenApiReference + { + Id = "pet", + Type = ReferenceType.Schema, + HostDocument = actual + }; + + var newPetSchema = Clone(components.Schemas["newPet"]); + + newPetSchema.Reference = new OpenApiReference + { + Id = "newPet", + Type = ReferenceType.Schema, + HostDocument = actual + }; + + var expected = new OpenApiDocument + { + Info = new OpenApiInfo + { + Version = "1.0.0", + Title = "Webhook Example" + }, + Webhooks = new Dictionary + { + ["/pets"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation + { + Description = "Returns all pets from the system that the user has access to", + OperationId = "findPets", + Parameters = new List + { + new OpenApiParameter + { + Name = "tags", + In = ParameterLocation.Query, + Description = "tags to filter by", + Required = false, + Schema = new OpenApiSchema + { + Type = "array", + Items = new OpenApiSchema + { + Type = "string" + } + } + }, + new OpenApiParameter + { + Name = "limit", + In = ParameterLocation.Query, + Description = "maximum number of results to return", + Required = false, + Schema = new OpenApiSchema + { + Type = "integer", + Format = "int32" + } + } + }, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "pet response", + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = "array", + Items = petSchema + } + }, + ["application/xml"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = "array", + Items = petSchema + } + } + } + } + } + }, + [OperationType.Post] = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody + { + Description = "Information about a new pet in the system", + Required = true, + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = newPetSchema + } + } + }, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "Return a 200 status to indicate that the data was received successfully", + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = petSchema + }, + } + } + } + } + } + } + }, + Components = components + }; + + // Assert + diagnostic.Should().BeEquivalentTo(new OpenApiDiagnostic() { SpecificationVersion = OpenApiSpecVersion.OpenApi3_0 }); + actual.Should().BeEquivalentTo(expected); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiDocument/documentWithWebhooks.yaml b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiDocument/documentWithWebhooks.yaml new file mode 100644 index 000000000..189835344 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiDocument/documentWithWebhooks.yaml @@ -0,0 +1,81 @@ +openapi: 3.1.0 +info: + title: Webhook Example + version: 1.0.0 +webhooks: + /pets: + get: + description: Returns all pets from the system that the user has access to + operationId: findPets + parameters: + - name: tags + in: query + description: tags to filter by + required: false + schema: + type: array + items: + type: string + - name: limit + in: query + description: maximum number of results to return + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + "$ref": '#/components/schemas/pet' + application/xml: + schema: + type: array + items: + "$ref": '#/components/schemas/pet' + post: + requestBody: + description: Information about a new pet in the system + required: true + content: + 'application/json': + schema: + "$ref": '#/components/schemas/newPet' + responses: + "200": + description: Return a 200 status to indicate that the data was received successfully + content: + application/json: + schema: + $ref: '#/components/schemas/pet' +components: + schemas: + pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + newPet: + type: object + required: + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDocumentWithWebhooksAsV3JsonWorks_produceTerseOutput=False.verified.txt b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDocumentWithWebhooksAsV3JsonWorks_produceTerseOutput=False.verified.txt new file mode 100644 index 000000000..73cc1b716 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDocumentWithWebhooksAsV3JsonWorks_produceTerseOutput=False.verified.txt @@ -0,0 +1,51 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Webhook Example", + "version": "1.0.0" + }, + "paths": { }, + "webhooks": { + "newPet": { + "post": { + "requestBody": { + "description": "Information about a new pet in the system", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "responses": { + "200": { + "description": "Return a 200 status to indicate that the data was received successfully" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDocumentWithWebhooksAsV3JsonWorks_produceTerseOutput=True.verified.txt b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDocumentWithWebhooksAsV3JsonWorks_produceTerseOutput=True.verified.txt new file mode 100644 index 000000000..a23dd5675 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.SerializeDocumentWithWebhooksAsV3JsonWorks_produceTerseOutput=True.verified.txt @@ -0,0 +1 @@ +{"openapi":"3.0.1","info":{"title":"Webhook Example","version":"1.0.0"},"paths":{},"webhooks":{"newPet":{"post":{"requestBody":{"description":"Information about a new pet in the system","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"responses":{"200":{"description":"Return a 200 status to indicate that the data was received successfully"}}}}},"components":{"schemas":{"Pet":{"required":["id","name"],"properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}}}}} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs index 89289397f..6a185b556 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -890,6 +890,78 @@ public class OpenApiDocumentTests Components = AdvancedComponents }; + public static OpenApiDocument DocumentWithWebhooks = new OpenApiDocument() + { + Info = new OpenApiInfo + { + Title = "Webhook Example", + Version = "1.0.0" + }, + Webhooks = new Dictionary + { + ["newPet"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Post] = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody + { + Description = "Information about a new pet in the system", + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = "Pet", + Type = ReferenceType.Schema + } + } + } + } + }, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "Return a 200 status to indicate that the data was received successfully" + } + } + } + } + } + }, + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Pet"] = new OpenApiSchema + { + Required = new HashSet { "id", "name" }, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema + { + Type = "integer", + Format = "int64" + }, + ["name"] = new OpenApiSchema + { + Type = "string" + }, + ["tag"] = new OpenApiSchema + { + Type = "string" + } + } + } + } + } + }; + public static OpenApiDocument DuplicateExtensions = new OpenApiDocument { Info = new OpenApiInfo @@ -1319,7 +1391,7 @@ public void SerializeRelativeRootPathWithHostAsV2JsonWorks() public void TestHashCodesForSimilarOpenApiDocuments() { // Arrange - var sampleFolderPath = "Models/Samples/"; + var sampleFolderPath = "Models/Samples/"; var doc1 = ParseInputFile(Path.Combine(sampleFolderPath, "sampleDocument.yaml")); var doc2 = ParseInputFile(Path.Combine(sampleFolderPath, "sampleDocument.yaml")); @@ -1356,5 +1428,68 @@ public void CopyConstructorForAdvancedDocumentWorks() Assert.Equal(2, doc.Paths.Count); Assert.NotNull(doc.Components); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async void SerializeDocumentWithWebhooksAsV3JsonWorks(bool produceTerseOutput) + { + // Arrange + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = produceTerseOutput }); + + // Act + DocumentWithWebhooks.SerializeAsV3(writer); + writer.Flush(); + var actual = outputStringWriter.GetStringBuilder().ToString(); + + // Assert + await Verifier.Verify(actual).UseParameters(produceTerseOutput); + } + + [Fact] + public void SerializeDocumentWithWebhooksAsV3YamlWorks() + { + // Arrange + var expected = @"openapi: 3.0.1 +info: + title: Webhook Example + version: 1.0.0 +paths: { } +webhooks: + newPet: + post: + requestBody: + description: Information about a new pet in the system + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '200': + description: Return a 200 status to indicate that the data was received successfully +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string"; + + // Act + var actual = DocumentWithWebhooks.SerializeAsYaml(OpenApiSpecVersion.OpenApi3_0); + + // Assert + actual = actual.MakeLineBreaksEnvironmentNeutral(); + expected = expected.MakeLineBreaksEnvironmentNeutral(); + Assert.Equal(expected, actual); + } } }