From 8d5a75851738f564dbd08ab6f1d763aa390b61d4 Mon Sep 17 00:00:00 2001 From: Peter Ombwa Date: Mon, 13 Nov 2023 13:50:42 -0800 Subject: [PATCH 1/7] feat: Adds ToApiManifest extension method. --- .../OpenApiDocumentExtensions.cs | 63 +++++++ .../OpenApiDocumentExtensionsTests.cs | 162 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 src/lib/TypeExtensions/OpenApiDocumentExtensions.cs create mode 100644 tests/ApiManifest.Tests/TypeExtensions/OpenApiDocumentExtensionsTests.cs diff --git a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs new file mode 100644 index 0000000..5095df9 --- /dev/null +++ b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.Helpers; +using Microsoft.OpenApi.Models; + +namespace Microsoft.OpenApi.ApiManifest.TypeExtensions +{ + public static class OpenApiDocumentExtensions + { + /// + /// Converts an to an . + /// + /// The OpenAPI document to convert. + /// The URL of the API description. + /// The name of the application. + /// The name of the API dependency. + /// An . + internal static ApiManifestDocument ToApiManifest(this OpenApiDocument document, string? apiDescriptionUrl, string applicationName, string? apiDependencyName = default) + { + ArgumentNullException.ThrowIfNull(document); + ValidationHelpers.ValidateNullOrWhitespace(nameof(apiDescriptionUrl), apiDescriptionUrl, nameof(ApiManifestDocument)); + ValidationHelpers.ValidateNullOrWhitespace(nameof(applicationName), applicationName, nameof(ApiManifestDocument)); + + var apiName = apiDependencyName ?? document.Info.Title.Trim().Replace(' ', '-'); // Normilize OpenAPI document title to API name by trimming and replacing spaces with dashes. + var publisherName = document.Info.Contact?.Name ?? "publisher-name"; + var publisherEmail = document.Info.Contact?.Email ?? "publisher-email@example.com"; + + string? apiDeploymentBaseUrl = default; + var server = document.Servers.FirstOrDefault(); + if (server is not null) + apiDeploymentBaseUrl = !server.Url.EndsWith("/", StringComparison.Ordinal) ? $"{server.Url}/" : server.Url; + + var apiManifest = new ApiManifestDocument(applicationName) + { + Publisher = new(publisherName, publisherEmail), + ApiDependencies = new() { + { + apiName, new() { + ApiDescriptionUrl = apiDescriptionUrl, + ApiDescriptionVersion = document.Info.Version, + ApiDeploymentBaseUrl = apiDeploymentBaseUrl, + } + } + } + }; + + foreach (var path in document.Paths) + { + foreach (var operation in path.Value.Operations) + { + var requestInfo = new RequestInfo + { + Method = operation.Key.ToString(), + UriTemplate = apiDeploymentBaseUrl != default ? path.Key.TrimStart('/') : path.Key + }; + apiManifest.ApiDependencies[apiName].Requests.Add(requestInfo); + } + } + return apiManifest; + } + } +} diff --git a/tests/ApiManifest.Tests/TypeExtensions/OpenApiDocumentExtensionsTests.cs b/tests/ApiManifest.Tests/TypeExtensions/OpenApiDocumentExtensionsTests.cs new file mode 100644 index 0000000..36aca43 --- /dev/null +++ b/tests/ApiManifest.Tests/TypeExtensions/OpenApiDocumentExtensionsTests.cs @@ -0,0 +1,162 @@ +using Microsoft.OpenApi.ApiManifest.TypeExtensions; +using Microsoft.OpenApi.Models; + +namespace Microsoft.OpenApi.ApiManifest.Tests.TypeExtensions +{ + public class OpenApiDocumentExtensionsTests + { + private readonly OpenApiDocument exampleDocument; + public OpenApiDocumentExtensionsTests() + { + exampleDocument = CreateDocument(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ToApiManifestWithNullApiDescriptionUrlThrowsArgumentException(string? apiDescriptionUrl) + { + // Arrange + var document = new OpenApiDocument(); + + // Act + var exception = Assert.Throws(() => document.ToApiManifest(apiDescriptionUrl, "application-name")); + + // Assert + Assert.Equal("apiDescriptionUrl", exception.ParamName); + } + + [Fact] + public void ToApiManifestWithNullApplicationNameThrowsArgumentException() + { + // Arrange + var document = new OpenApiDocument(); + var apiDescriptionUrl = "https://example.com/api-description.yaml"; + + // Act + var exception = Assert.Throws(() => document.ToApiManifest(apiDescriptionUrl, string.Empty)); + + // Assert + Assert.Equal("applicationName", exception.ParamName); + } + + [Fact] + public void ToApiManifestWithValidDocumentReturnsApiManifestDocument() + { + // Arrange + var apiDescriptionUrl = "https://example.com/api-description.yaml"; + var applicationName = "application-name"; + + // Act + var apiManifest = exampleDocument.ToApiManifest(apiDescriptionUrl, applicationName); + + // Assert + Assert.NotNull(apiManifest); + Assert.Equal(applicationName, apiManifest.ApplicationName); + Assert.NotNull(apiManifest.Publisher); + Assert.Equal(exampleDocument.Info.Contact?.Name, apiManifest.Publisher?.Name); + Assert.Equal(exampleDocument.Info.Contact?.Email, apiManifest.Publisher?.ContactEmail); + Assert.NotNull(apiManifest.ApiDependencies); + _ = Assert.Single(apiManifest.ApiDependencies); + Assert.Equal(exampleDocument.Info.Title.Trim().Replace(' ', '-'), apiManifest.ApiDependencies.First().Key); + Assert.Equal(apiDescriptionUrl, apiManifest.ApiDependencies.First().Value.ApiDescriptionUrl); + Assert.Equal(exampleDocument.Info.Version, apiManifest.ApiDependencies.First().Value.ApiDescriptionVersion); + Assert.Equal(exampleDocument.Servers.First().Url, apiManifest.ApiDependencies.First().Value.ApiDeploymentBaseUrl); + Assert.Equal(exampleDocument.Paths.Count, apiManifest.ApiDependencies.First().Value.Requests.Count); + } + + [Fact] + public void ToApiManifestWithValidDocumentAndApiDependencyNameReturnsApiManifestDocument() + { + // Arrange + var apiDescriptionUrl = "https://example.com/api-description.yaml"; + var applicationName = "application-name"; + var apiDependencyName = "graph"; + + // Act + var apiManifest = exampleDocument.ToApiManifest(apiDescriptionUrl, applicationName, apiDependencyName); + + // Assert + Assert.NotNull(apiManifest); + Assert.Equal(applicationName, apiManifest.ApplicationName); + Assert.NotNull(apiManifest.Publisher); + Assert.Equal(exampleDocument.Info.Contact?.Name, apiManifest.Publisher?.Name); + Assert.Equal(exampleDocument.Info.Contact?.Email, apiManifest.Publisher?.ContactEmail); + Assert.NotNull(apiManifest.ApiDependencies); + _ = Assert.Single(apiManifest.ApiDependencies); + Assert.Equal(apiDependencyName, apiManifest.ApiDependencies.First().Key); + Assert.Equal(apiDescriptionUrl, apiManifest.ApiDependencies.First().Value.ApiDescriptionUrl); + Assert.Equal(exampleDocument.Info.Version, apiManifest.ApiDependencies.First().Value.ApiDescriptionVersion); + Assert.Equal(exampleDocument.Servers.First().Url, apiManifest.ApiDependencies.First().Value.ApiDeploymentBaseUrl); + Assert.Equal(exampleDocument.Paths.Count, apiManifest.ApiDependencies.First().Value.Requests.Count); + } + + [Fact] + public void ToApiManifestWithValidDocumentAndApiDependencyNameAndApiDeploymentBaseUrlReturnsApiManifestDocument() + { + // Arrange + var apiDescriptionUrl = "https://example.com/api-description.yaml"; + var applicationName = "application-name"; + var apiDependencyName = "graph"; + var apiDeploymentBaseUrl = "https://example.com/api/"; + + // Act + var apiManifest = exampleDocument.ToApiManifest(apiDescriptionUrl, applicationName, apiDependencyName); + + // Assert + Assert.NotNull(apiManifest); + Assert.Equal(applicationName, apiManifest.ApplicationName); + Assert.NotNull(apiManifest.Publisher); + Assert.Equal(exampleDocument.Info.Contact?.Name, apiManifest.Publisher?.Name); + Assert.Equal(exampleDocument.Info.Contact?.Email, apiManifest.Publisher?.ContactEmail); + Assert.NotNull(apiManifest.ApiDependencies); + _ = Assert.Single(apiManifest.ApiDependencies); + Assert.Equal(apiDependencyName, apiManifest.ApiDependencies.First().Key); + Assert.Equal(apiDescriptionUrl, apiManifest.ApiDependencies.First().Value.ApiDescriptionUrl); + Assert.Equal(exampleDocument.Info.Version, apiManifest.ApiDependencies.First().Value.ApiDescriptionVersion); + Assert.Equal(apiDeploymentBaseUrl, apiManifest.ApiDependencies.First().Value.ApiDeploymentBaseUrl); + Assert.Equal(exampleDocument.Paths.Count, apiManifest.ApiDependencies.First().Value.Requests.Count); + } + + private static OpenApiDocument CreateDocument() + { + return new OpenApiDocument + { + Info = new OpenApiInfo + { + Title = "Graph API", + Version = "v1.0", + Contact = new OpenApiContact + { + Name = "publisher-name", + Email = "foo@bar.com" + } + }, + Servers = new List + { + new OpenApiServer + { + Url = "https://example.com/api/" + } + }, + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation() + } + }, + ["/groups"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation() + } + } + } + }; + } + } +} From cc798b3e9806994dbb264eb92b9db984275da50e Mon Sep 17 00:00:00 2001 From: Peter Ombwa Date: Mon, 13 Nov 2023 16:10:45 -0800 Subject: [PATCH 2/7] chore: Adds OpenAPI to API Manifest mapping doc. --- docs/OpenApiToApiManifestMapping.md | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/OpenApiToApiManifestMapping.md diff --git a/docs/OpenApiToApiManifestMapping.md b/docs/OpenApiToApiManifestMapping.md new file mode 100644 index 0000000..c3596d7 --- /dev/null +++ b/docs/OpenApiToApiManifestMapping.md @@ -0,0 +1,38 @@ +# OpenAPI to API Manifest Mapping + +## Overview + +This document specifies the requirements and procedures for converting an OpenAPI document to an API manifest document. An OpenAPI document is a standard format for describing the interface and operations of a web service. An API manifest is a way to store the dependencies that an application has on HTTP APIs. + +## Mapping Diagram + +The following diagram illustrates the mapping from the OpenAPI document to the API manifest document. + +```mermaid +graph LR + subgraph OpenApiDocument + A1[Info.Contact.Name] + A2[Info.Contact.Email] + A3[Info.Title] + A4[Servers.Url] + A5[Info.Version] + A6[Paths.Key] + A7[Paths.Operations.Key] + end + subgraph ApiManifestDocument + B1[Publisher.Name] + B2[Publisher.Email] + B3[ApiDependencies.ApiName] + B4[ApiDependencies.ApiDeploymentBaseUrl] + B5[ApiDependencies.ApiDescriptionVersion] + B6[ApiDependencies.Requests.UriTemplate] + B7[ApiDependencies.Requests.Method] + end + A1 -- "1" --> B1 + A2 -- "2" --> B2 + A3 -- "3" --> B3 + A4 -- "4" --> B4 + A5 -- "5" --> B5 + A6 -- "6" --> B6 + A7 -- "7" --> B7 +``` From 8db5cef4afcc7be7fb2e31cc6aff6066beb3b36c Mon Sep 17 00:00:00 2001 From: Peter Ombwa Date: Tue, 14 Nov 2023 14:44:05 -0800 Subject: [PATCH 3/7] chore: Update changelog. --- CHANGELOG.md | 6 +++++ docs/OpenApiToApiManifestMapping.md | 40 ++++++++++++++++++----------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 985823d..cff3a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added ToApiManifest extension method on OpenApiDocument. #46 + +## [0.5.2] - 2023-11-06 + +### Added + - Added support for conversion of API manifest document to OpenAI Plugin manifest. #4 - Added VerificationTokens property to OpenAI Plugin manifest auth type. #32 - Added OpenAI Plugin manifest validation. #32 diff --git a/docs/OpenApiToApiManifestMapping.md b/docs/OpenApiToApiManifestMapping.md index c3596d7..dbde21c 100644 --- a/docs/OpenApiToApiManifestMapping.md +++ b/docs/OpenApiToApiManifestMapping.md @@ -2,13 +2,13 @@ ## Overview -This document specifies the requirements and procedures for converting an OpenAPI document to an API manifest document. An OpenAPI document is a standard format for describing the interface and operations of a web service. An API manifest is a way to store the dependencies that an application has on HTTP APIs. +This document provides a mapping that's used to convert an OpenAPI document to an API manifest document. An OpenAPI document is a standard format for describing the interface and operations of a web service. An API manifest is a standard that's used to declare an application's HTTP API dependencies and includes links to API descriptions, specifics of HTTP API requests, and related authorization details. ## Mapping Diagram -The following diagram illustrates the mapping from the OpenAPI document to the API manifest document. +The following diagram illustrates how an OpenAPI document is mapped to an API manifest document. -```mermaid +``` mermaid graph LR subgraph OpenApiDocument A1[Info.Contact.Name] @@ -22,17 +22,27 @@ graph LR subgraph ApiManifestDocument B1[Publisher.Name] B2[Publisher.Email] - B3[ApiDependencies.ApiName] - B4[ApiDependencies.ApiDeploymentBaseUrl] - B5[ApiDependencies.ApiDescriptionVersion] - B6[ApiDependencies.Requests.UriTemplate] - B7[ApiDependencies.Requests.Method] + B3[ApiDependencies.Key] + B4["ApiDependencies[key].ApiDeploymentBaseUrl"] + B5["ApiDependencies[key].ApiDescriptionVersion"] + B6["ApiDependencies[key].Requests.UriTemplate"] + B7["ApiDependencies[key].Requests.Method"] end - A1 -- "1" --> B1 - A2 -- "2" --> B2 - A3 -- "3" --> B3 - A4 -- "4" --> B4 - A5 -- "5" --> B5 - A6 -- "6" --> B6 - A7 -- "7" --> B7 + A1 -- "( 1 )" --> B1 + A2 -- "( 2 )" --> B2 + A3 -- "( 3 )" --> B3 + A4 -- "( 4 )" --> B4 + A5 -- "( 5 )" --> B5 + A6 -- "( 6 )" --> B6 + A7 -- "( 7 )" --> B7 ``` + +### Mapping Steps + +1. `Publisher.Name`: If `Info.Contact.Name` is present in the OpenAPI document, it maps to `Publisher.Name` in the API Manifest document. If not, the default value is `publisher-name`. This field is required in the API Manifest. +2. `Publisher.Email`: If `Info.Contact.Email` is present in the OpenAPI document, it maps to `Publisher.Email` in the API Manifest document. If not, the default value is `publisher-email@example.com`. This field is required in the API Manifest. +3. `ApiDependencies.Key`: If a customer doesn't provide a key for an ApiDependency in the API Manifest document, the `Info.Title` from the OpenAPI document is used. The converter modifies the `Info.Title` value by removing any leading or trailing whitespace and replacing any spaces between words with `-` +4. `ApiDependencies[key].ApiDeploymentBaseUrl`: If the `Servers` field in the OpenAPI document contains at least one server, the URL of the first server maps to this field in the API Manifest document. If not, this field is assumed to be null. +5. `ApiDependencies[key].ApiDescriptionVersion`: The `Info.Version` from the OpenAPI document maps to this field in the API Manifest document. +6. `ApiDependencies[key].Requests.UriTemplate`: The `Paths.Key` from the OpenAPI document maps to `Requests.UriTemplate` field in the API Manifest document. +7. `ApiDependencies[key].Requests.Method`: The `Paths.Operations.Key` from the OpenAPI document maps to `Requests.Method` field in the API Manifest document. From 95f68f6f671f040191c188e426be3089e33a67fa Mon Sep 17 00:00:00 2001 From: Peter Ombwa Date: Tue, 14 Nov 2023 14:59:21 -0800 Subject: [PATCH 4/7] chore: Use constants for default values. --- .../OpenApiDocumentExtensions.cs | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs index 5095df9..7ebd89d 100644 --- a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs +++ b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs @@ -8,6 +8,9 @@ namespace Microsoft.OpenApi.ApiManifest.TypeExtensions { public static class OpenApiDocumentExtensions { + private const string DefaultPublisherName = "publisher-name"; + private const string DefaultPublisherEmail = "publisher-email@example.com"; + /// /// Converts an to an . /// @@ -16,27 +19,24 @@ public static class OpenApiDocumentExtensions /// The name of the application. /// The name of the API dependency. /// An . - internal static ApiManifestDocument ToApiManifest(this OpenApiDocument document, string? apiDescriptionUrl, string applicationName, string? apiDependencyName = default) + public static ApiManifestDocument ToApiManifest(this OpenApiDocument document, string? apiDescriptionUrl, string applicationName, string? apiDependencyName = default) { ArgumentNullException.ThrowIfNull(document); ValidationHelpers.ValidateNullOrWhitespace(nameof(apiDescriptionUrl), apiDescriptionUrl, nameof(ApiManifestDocument)); ValidationHelpers.ValidateNullOrWhitespace(nameof(applicationName), applicationName, nameof(ApiManifestDocument)); - var apiName = apiDependencyName ?? document.Info.Title.Trim().Replace(' ', '-'); // Normilize OpenAPI document title to API name by trimming and replacing spaces with dashes. - var publisherName = document.Info.Contact?.Name ?? "publisher-name"; - var publisherEmail = document.Info.Contact?.Email ?? "publisher-email@example.com"; + apiDependencyName = NormalizeApiName(apiDependencyName ?? document.Info.Title); + var publisherName = document.Info.Contact?.Name ?? DefaultPublisherName; + var publisherEmail = document.Info.Contact?.Email ?? DefaultPublisherEmail; - string? apiDeploymentBaseUrl = default; - var server = document.Servers.FirstOrDefault(); - if (server is not null) - apiDeploymentBaseUrl = !server.Url.EndsWith("/", StringComparison.Ordinal) ? $"{server.Url}/" : server.Url; + string? apiDeploymentBaseUrl = GetApiDeploymentBaseUrl(document.Servers.FirstOrDefault()); var apiManifest = new ApiManifestDocument(applicationName) { Publisher = new(publisherName, publisherEmail), ApiDependencies = new() { { - apiName, new() { + apiDependencyName, new() { ApiDescriptionUrl = apiDescriptionUrl, ApiDescriptionVersion = document.Info.Version, ApiDeploymentBaseUrl = apiDeploymentBaseUrl, @@ -54,10 +54,25 @@ internal static ApiManifestDocument ToApiManifest(this OpenApiDocument document, Method = operation.Key.ToString(), UriTemplate = apiDeploymentBaseUrl != default ? path.Key.TrimStart('/') : path.Key }; - apiManifest.ApiDependencies[apiName].Requests.Add(requestInfo); + apiManifest.ApiDependencies[apiDependencyName].Requests.Add(requestInfo); } } return apiManifest; } + + private static string NormalizeApiName(string apiName) + { + // Normalize OpenAPI document title to API name by trimming and replacing spaces with dashes. + return apiName.Trim().Replace(' ', '-'); + } + + private static string? GetApiDeploymentBaseUrl(OpenApiServer? server) + { + if (server is null) + return null; + + // Ensure the base URL ends with a slash. + return !server.Url.EndsWith("/", StringComparison.Ordinal) ? $"{server.Url}/" : server.Url; + } } } From f31fcfabf0b63cd3a03fcbdc8d64f9778399b613 Mon Sep 17 00:00:00 2001 From: Peter Ombwa Date: Wed, 15 Nov 2023 15:30:04 -0800 Subject: [PATCH 5/7] chore: Allow customers to provide publisher info. --- docs/OpenApiToApiManifestMapping.md | 6 +- .../OpenApiDocumentExtensions.cs | 28 ++++++--- .../OpenApiDocumentExtensionsTests.cs | 62 ++++++++++++++++++- 3 files changed, 81 insertions(+), 15 deletions(-) diff --git a/docs/OpenApiToApiManifestMapping.md b/docs/OpenApiToApiManifestMapping.md index dbde21c..9e5957a 100644 --- a/docs/OpenApiToApiManifestMapping.md +++ b/docs/OpenApiToApiManifestMapping.md @@ -39,9 +39,9 @@ graph LR ### Mapping Steps -1. `Publisher.Name`: If `Info.Contact.Name` is present in the OpenAPI document, it maps to `Publisher.Name` in the API Manifest document. If not, the default value is `publisher-name`. This field is required in the API Manifest. -2. `Publisher.Email`: If `Info.Contact.Email` is present in the OpenAPI document, it maps to `Publisher.Email` in the API Manifest document. If not, the default value is `publisher-email@example.com`. This field is required in the API Manifest. -3. `ApiDependencies.Key`: If a customer doesn't provide a key for an ApiDependency in the API Manifest document, the `Info.Title` from the OpenAPI document is used. The converter modifies the `Info.Title` value by removing any leading or trailing whitespace and replacing any spaces between words with `-` +1. `Publisher.Name`: If a customer does not provide the publisher name, the `Info.Contact.Name` from the OpenAPI document is used as the `Publisher.Name` in the API Manifest document. If the OpenAPI document does not contain `Info.Contact.Name`, a default value of `publisher-name` is used. This field is required in the API Manifest. +2. `Publisher.Email`: If a customer does not provide the publisher email, the `Info.Contact.Email` from the OpenAPI document is used as the `Publisher.Email` in the API Manifest document. If the OpenAPI document does not contain `Info.Contact.Email`, a default value of `publisher-email@example.com` is used. This field is required in the API Manifest. +3. `ApiDependencies.Key`: If a customer doesn't provide a key for an ApiDependency in the API Manifest document, the `Info.Title` from the OpenAPI document is used as the api dependency key. The converter normalizes the `Info.Title` value by removing all special characters and whitespace. 4. `ApiDependencies[key].ApiDeploymentBaseUrl`: If the `Servers` field in the OpenAPI document contains at least one server, the URL of the first server maps to this field in the API Manifest document. If not, this field is assumed to be null. 5. `ApiDependencies[key].ApiDescriptionVersion`: The `Info.Version` from the OpenAPI document maps to this field in the API Manifest document. 6. `ApiDependencies[key].Requests.UriTemplate`: The `Paths.Key` from the OpenAPI document maps to `Requests.UriTemplate` field in the API Manifest document. diff --git a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs index 7ebd89d..694c2ab 100644 --- a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs +++ b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs @@ -3,13 +3,16 @@ using Microsoft.OpenApi.ApiManifest.Helpers; using Microsoft.OpenApi.Models; +using System.Text.RegularExpressions; namespace Microsoft.OpenApi.ApiManifest.TypeExtensions { - public static class OpenApiDocumentExtensions + public static partial class OpenApiDocumentExtensions { - private const string DefaultPublisherName = "publisher-name"; - private const string DefaultPublisherEmail = "publisher-email@example.com"; + [GeneratedRegex("[^a-zA-Z0-9]", RegexOptions.Compiled, 5000)] + private static partial Regex ApiNameAllowedCharactersRegex(); + internal const string DefaultPublisherName = "publisher-name"; + internal const string DefaultPublisherEmail = "publisher-email@example.com"; /// /// Converts an to an . @@ -17,18 +20,23 @@ public static class OpenApiDocumentExtensions /// The OpenAPI document to convert. /// The URL of the API description. /// The name of the application. - /// The name of the API dependency. + /// The name of the API dependency. If not specified, it defaults to the title from the OpenAPI document. + /// The publisher name of the API manifest. If not supplied, it defaults to the contact name from the OpenAPI document, if available. In the absence of both, 'publisher-name' is used as a fallback. + /// The publisher email of the API manifest. If not supplied, it defaults to the contact email from the OpenAPI document, if available.In the absence of both, 'publisher-email@example.com' is used as a fallback. /// An . - public static ApiManifestDocument ToApiManifest(this OpenApiDocument document, string? apiDescriptionUrl, string applicationName, string? apiDependencyName = default) + public static ApiManifestDocument ToApiManifest(this OpenApiDocument document, string? apiDescriptionUrl, string applicationName, string? apiDependencyName = default, string? publisherName = default, string? publisherEmail = default) { ArgumentNullException.ThrowIfNull(document); ValidationHelpers.ValidateNullOrWhitespace(nameof(apiDescriptionUrl), apiDescriptionUrl, nameof(ApiManifestDocument)); ValidationHelpers.ValidateNullOrWhitespace(nameof(applicationName), applicationName, nameof(ApiManifestDocument)); - apiDependencyName = NormalizeApiName(apiDependencyName ?? document.Info.Title); - var publisherName = document.Info.Contact?.Name ?? DefaultPublisherName; - var publisherEmail = document.Info.Contact?.Email ?? DefaultPublisherEmail; + if (string.IsNullOrEmpty(publisherName)) + publisherName = document.Info.Contact?.Name ?? DefaultPublisherName; + if (string.IsNullOrEmpty(publisherEmail)) + publisherEmail = document.Info.Contact?.Email ?? DefaultPublisherEmail; + + apiDependencyName = NormalizeApiName(string.IsNullOrEmpty(apiDependencyName) ? document.Info.Title : apiDependencyName); string? apiDeploymentBaseUrl = GetApiDeploymentBaseUrl(document.Servers.FirstOrDefault()); var apiManifest = new ApiManifestDocument(applicationName) @@ -62,8 +70,8 @@ public static ApiManifestDocument ToApiManifest(this OpenApiDocument document, s private static string NormalizeApiName(string apiName) { - // Normalize OpenAPI document title to API name by trimming and replacing spaces with dashes. - return apiName.Trim().Replace(' ', '-'); + // Normalize OpenAPI document title to API dependency name by removing all special characters from the provided api name. + return ApiNameAllowedCharactersRegex().Replace(apiName, string.Empty); } private static string? GetApiDeploymentBaseUrl(OpenApiServer? server) diff --git a/tests/ApiManifest.Tests/TypeExtensions/OpenApiDocumentExtensionsTests.cs b/tests/ApiManifest.Tests/TypeExtensions/OpenApiDocumentExtensionsTests.cs index 36aca43..78ee9c2 100644 --- a/tests/ApiManifest.Tests/TypeExtensions/OpenApiDocumentExtensionsTests.cs +++ b/tests/ApiManifest.Tests/TypeExtensions/OpenApiDocumentExtensionsTests.cs @@ -58,7 +58,7 @@ public void ToApiManifestWithValidDocumentReturnsApiManifestDocument() Assert.Equal(exampleDocument.Info.Contact?.Email, apiManifest.Publisher?.ContactEmail); Assert.NotNull(apiManifest.ApiDependencies); _ = Assert.Single(apiManifest.ApiDependencies); - Assert.Equal(exampleDocument.Info.Title.Trim().Replace(' ', '-'), apiManifest.ApiDependencies.First().Key); + Assert.Equal("GraphAPI", apiManifest.ApiDependencies.First().Key); Assert.Equal(apiDescriptionUrl, apiManifest.ApiDependencies.First().Value.ApiDescriptionUrl); Assert.Equal(exampleDocument.Info.Version, apiManifest.ApiDependencies.First().Value.ApiDescriptionVersion); Assert.Equal(exampleDocument.Servers.First().Url, apiManifest.ApiDependencies.First().Value.ApiDeploymentBaseUrl); @@ -118,13 +118,71 @@ public void ToApiManifestWithValidDocumentAndApiDependencyNameAndApiDeploymentBa Assert.Equal(exampleDocument.Paths.Count, apiManifest.ApiDependencies.First().Value.Requests.Count); } + [Fact] + public void ToApiManifestWhenOpenApiContactInfoIsNullAndNoPublisherInfoIIsProvidedReturnsApiManifestDocument() + { + // Arrange + var apiDescriptionUrl = "https://example.com/api-description.yaml"; + var applicationName = "application-name"; + var apiDependencyName = "graph"; + var apiDeploymentBaseUrl = "https://example.com/api/"; + var localExampleDocument = CreateDocument(); + localExampleDocument.Info.Contact = null; + + // Act + var apiManifest = localExampleDocument.ToApiManifest(apiDescriptionUrl, applicationName, apiDependencyName); + + // Assert + Assert.NotNull(apiManifest); + Assert.Equal(applicationName, apiManifest.ApplicationName); + Assert.NotNull(apiManifest.Publisher); + Assert.Equal(OpenApiDocumentExtensions.DefaultPublisherName, apiManifest.Publisher?.Name); + Assert.Equal(OpenApiDocumentExtensions.DefaultPublisherEmail, apiManifest.Publisher?.ContactEmail); + Assert.NotNull(apiManifest.ApiDependencies); + _ = Assert.Single(apiManifest.ApiDependencies); + Assert.Equal(apiDependencyName, apiManifest.ApiDependencies.First().Key); + Assert.Equal(apiDescriptionUrl, apiManifest.ApiDependencies.First().Value.ApiDescriptionUrl); + Assert.Equal(localExampleDocument.Info.Version, apiManifest.ApiDependencies.First().Value.ApiDescriptionVersion); + Assert.Equal(apiDeploymentBaseUrl, apiManifest.ApiDependencies.First().Value.ApiDeploymentBaseUrl); + Assert.Equal(localExampleDocument.Paths.Count, apiManifest.ApiDependencies.First().Value.Requests.Count); + } + + [Fact] + public void ToApiManifestWithAllParametersReturnsApiManifestDocument() + { + // Arrange + var apiDescriptionUrl = "https://example.com/api-description.yaml"; + var applicationName = "application-name"; + var apiDependencyName = "graph"; + var apiDeploymentBaseUrl = "https://example.com/api/"; + var publisherName = "FooBar"; + var publisherEmail = "FooBar@contoso.com"; + + // Act + var apiManifest = exampleDocument.ToApiManifest(apiDescriptionUrl, applicationName, apiDependencyName, publisherName, publisherEmail); + + // Assert + Assert.NotNull(apiManifest); + Assert.Equal(applicationName, apiManifest.ApplicationName); + Assert.NotNull(apiManifest.Publisher); + Assert.Equal(publisherName, apiManifest.Publisher?.Name); + Assert.Equal(publisherEmail, apiManifest.Publisher?.ContactEmail); + Assert.NotNull(apiManifest.ApiDependencies); + _ = Assert.Single(apiManifest.ApiDependencies); + Assert.Equal(apiDependencyName, apiManifest.ApiDependencies.First().Key); + Assert.Equal(apiDescriptionUrl, apiManifest.ApiDependencies.First().Value.ApiDescriptionUrl); + Assert.Equal(exampleDocument.Info.Version, apiManifest.ApiDependencies.First().Value.ApiDescriptionVersion); + Assert.Equal(apiDeploymentBaseUrl, apiManifest.ApiDependencies.First().Value.ApiDeploymentBaseUrl); + Assert.Equal(exampleDocument.Paths.Count, apiManifest.ApiDependencies.First().Value.Requests.Count); + } + private static OpenApiDocument CreateDocument() { return new OpenApiDocument { Info = new OpenApiInfo { - Title = "Graph API", + Title = " Graph + API ", Version = "v1.0", Contact = new OpenApiContact { From 6a4bbf97bb1be28ed229702f66790de935cf29f5 Mon Sep 17 00:00:00 2001 From: Peter Ombwa Date: Wed, 15 Nov 2023 15:44:15 -0800 Subject: [PATCH 6/7] chore: Rename source generated REGEX. --- src/lib/TypeExtensions/OpenApiDocumentExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs index 694c2ab..16d58aa 100644 --- a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs +++ b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs @@ -10,7 +10,7 @@ namespace Microsoft.OpenApi.ApiManifest.TypeExtensions public static partial class OpenApiDocumentExtensions { [GeneratedRegex("[^a-zA-Z0-9]", RegexOptions.Compiled, 5000)] - private static partial Regex ApiNameAllowedCharactersRegex(); + private static partial Regex SpecialCharactersInApiNameRegex(); internal const string DefaultPublisherName = "publisher-name"; internal const string DefaultPublisherEmail = "publisher-email@example.com"; @@ -71,7 +71,7 @@ public static ApiManifestDocument ToApiManifest(this OpenApiDocument document, s private static string NormalizeApiName(string apiName) { // Normalize OpenAPI document title to API dependency name by removing all special characters from the provided api name. - return ApiNameAllowedCharactersRegex().Replace(apiName, string.Empty); + return SpecialCharactersInApiNameRegex().Replace(apiName, string.Empty); } private static string? GetApiDeploymentBaseUrl(OpenApiServer? server) From ee957a55c30bc09b45d81fcff9a2ffa18ead8393 Mon Sep 17 00:00:00 2001 From: Peter Ombwa Date: Thu, 16 Nov 2023 11:07:24 -0800 Subject: [PATCH 7/7] chore: Handle empty contact info. --- CHANGELOG.md | 2 +- .../TypeExtensions/OpenApiDocumentExtensions.cs | 16 ++++++++++++---- src/lib/apimanifest.csproj | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cff3a50..054646e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.3] - 2023-11-16 ### Added diff --git a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs index 16d58aa..e817ff7 100644 --- a/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs +++ b/src/lib/TypeExtensions/OpenApiDocumentExtensions.cs @@ -21,8 +21,16 @@ public static partial class OpenApiDocumentExtensions /// The URL of the API description. /// The name of the application. /// The name of the API dependency. If not specified, it defaults to the title from the OpenAPI document. - /// The publisher name of the API manifest. If not supplied, it defaults to the contact name from the OpenAPI document, if available. In the absence of both, 'publisher-name' is used as a fallback. - /// The publisher email of the API manifest. If not supplied, it defaults to the contact email from the OpenAPI document, if available.In the absence of both, 'publisher-email@example.com' is used as a fallback. + /// + /// The publisher's name for the API manifest. + /// If not provided, it defaults to the contact name from the OpenAPI document (if available). + /// If the contact name is also not available, it defaults to 'publisher-name'. + /// + /// + /// The publisher's email for the API manifest. + /// If not provided, it defaults to the contact email from the OpenAPI document (if available). + /// If the contact email is also not available, it defaults to 'publisher-email@example.com'. + /// /// An . public static ApiManifestDocument ToApiManifest(this OpenApiDocument document, string? apiDescriptionUrl, string applicationName, string? apiDependencyName = default, string? publisherName = default, string? publisherEmail = default) { @@ -31,10 +39,10 @@ public static ApiManifestDocument ToApiManifest(this OpenApiDocument document, s ValidationHelpers.ValidateNullOrWhitespace(nameof(applicationName), applicationName, nameof(ApiManifestDocument)); if (string.IsNullOrEmpty(publisherName)) - publisherName = document.Info.Contact?.Name ?? DefaultPublisherName; + publisherName = document.Info.Contact?.Name is string cName && !string.IsNullOrEmpty(cName) ? cName : DefaultPublisherName; if (string.IsNullOrEmpty(publisherEmail)) - publisherEmail = document.Info.Contact?.Email ?? DefaultPublisherEmail; + publisherEmail = document.Info.Contact?.Email is string cEmail && !string.IsNullOrEmpty(cEmail) ? cEmail : DefaultPublisherEmail; apiDependencyName = NormalizeApiName(string.IsNullOrEmpty(apiDependencyName) ? document.Info.Title : apiDependencyName); string? apiDeploymentBaseUrl = GetApiDeploymentBaseUrl(document.Servers.FirstOrDefault()); diff --git a/src/lib/apimanifest.csproj b/src/lib/apimanifest.csproj index 4488efb..3f45d92 100644 --- a/src/lib/apimanifest.csproj +++ b/src/lib/apimanifest.csproj @@ -3,7 +3,7 @@ net7.0 Microsoft.OpenApi.ApiManifest - 0.5.2 + 0.5.3 preview http://go.microsoft.com/fwlink/?LinkID=288890 https://github.com/Microsoft/OpenApi.ApiManifest