Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds ToApiManifest extension method for converting an OpenAPI document to an APIManifest #48

Merged
merged 9 commits into from
Nov 16, 2023
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions docs/OpenApiToApiManifestMapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# OpenAPI to API Manifest Mapping

## Overview

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 how an OpenAPI document is mapped to an 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.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
```

### 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 `[email protected]`. This field is required in the API Manifest.
peombwa marked this conversation as resolved.
Show resolved Hide resolved
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.
78 changes: 78 additions & 0 deletions src/lib/TypeExtensions/OpenApiDocumentExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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
{
private const string DefaultPublisherName = "publisher-name";
private const string DefaultPublisherEmail = "[email protected]";

/// <summary>
/// Converts an <see cref="OpenApiDocument"/> to an <see cref="ApiManifestDocument"/>.
/// </summary>
/// <param name="document">The OpenAPI document to convert.</param>
/// <param name="apiDescriptionUrl">The URL of the API description.</param>
/// <param name="applicationName">The name of the application.</param>
/// <param name="apiDependencyName">The name of the API dependency.</param>
/// <returns>An <see cref="ApiManifestDocument"/>.</returns>
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));

apiDependencyName = NormalizeApiName(apiDependencyName ?? document.Info.Title);
peombwa marked this conversation as resolved.
Show resolved Hide resolved
var publisherName = document.Info.Contact?.Name ?? DefaultPublisherName;
var publisherEmail = document.Info.Contact?.Email ?? DefaultPublisherEmail;
peombwa marked this conversation as resolved.
Show resolved Hide resolved

string? apiDeploymentBaseUrl = GetApiDeploymentBaseUrl(document.Servers.FirstOrDefault());

var apiManifest = new ApiManifestDocument(applicationName)
{
Publisher = new(publisherName, publisherEmail),
ApiDependencies = new() {
{
apiDependencyName, 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[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(' ', '-');
peombwa marked this conversation as resolved.
Show resolved Hide resolved
}

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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentNullException>(() => 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<ArgumentNullException>(() => 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 = "[email protected]"
}
},
Servers = new List<OpenApiServer>
{
new OpenApiServer
{
Url = "https://example.com/api/"
}
},
Paths = new OpenApiPaths
{
["/users"] = new OpenApiPathItem
{
Operations = new Dictionary<OperationType, OpenApiOperation>
{
[OperationType.Get] = new OpenApiOperation()
}
},
["/groups"] = new OpenApiPathItem
{
Operations = new Dictionary<OperationType, OpenApiOperation>
{
[OperationType.Get] = new OpenApiOperation()
}
}
}
};
}
}
}