Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .azure-pipelines/ci-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ stages:
displayName: 'Run CredScan - Test'
inputs:
toolMajorVersion: 'V2'
scanFolder: '$(Build.SourcesDirectory)\src\tests'
scanFolder: '$(Build.SourcesDirectory)\tests'
debugMode: false

- task: AntiMalware@3
Expand Down
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @peombwa @darrelmiller @baywet
* @peombwa @darrelmiller @baywet @zengin
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Changelog

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]

### 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
- Added API Manifest validation. #5
- Added ApplicationName property to ApiManifestDocument. #5

### Changed

- Renamed Request class to RequestInfo to align with the API manifest specification. #21
- Renamed Auth property in ApiDependency to AuthorizationRequirements to align with the API manifest specification. #5

## [0.5.1] - 2023-08-17

### Changed

- Fixed typos in properties.

## [0.5.0] - 2023-08-17

### Added

- Initial release of the library.
19 changes: 6 additions & 13 deletions src/lib/ApiManifestDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,27 @@ public class ApiManifestDocument

public ApiManifestDocument(string applicationName)
{
Validate(applicationName);

ApplicationName = applicationName;
Validate();
}

public ApiManifestDocument(JsonElement value)
{
ParsingHelpers.ParseMap(value, this, handlers);

Validate(ApplicationName);
Validate();
}

// Write method
public void Write(Utf8JsonWriter writer)
{
Validate(ApplicationName);

Validate();
writer.WriteStartObject();

writer.WriteString(ApplicationNameProperty, ApplicationName);

if (Publisher != null)
{
writer.WritePropertyName(PublisherProperty);
Publisher.Write(writer);
}

if (ApiDependencies.Any())
{
writer.WritePropertyName(ApiDependenciesProperty);
Expand All @@ -55,13 +49,11 @@ public void Write(Utf8JsonWriter writer)
}
writer.WriteEndObject();
}

if (Extensions.Any())
{
writer.WritePropertyName(ExtensionsProperty);
Extensions.Write(writer);
}

writer.WriteEndObject();
}

Expand All @@ -71,9 +63,10 @@ public static ApiManifestDocument Load(JsonElement value)
return new ApiManifestDocument(value);
}

private static void Validate(string? applicationName)
internal void Validate()
{
ValidationHelpers.ValidateNullOrWhitespace(nameof(applicationName), applicationName, nameof(ApiManifestDocument));
ValidationHelpers.ValidateNullOrWhitespace(nameof(ApplicationName), ApplicationName, nameof(ApiManifestDocument));
Publisher?.Validate();
}

// Create fixed field map for ApiManifest
Expand Down
2 changes: 2 additions & 0 deletions src/lib/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ internal static class ErrorMessage
public static readonly string FieldIsNotValid = "'{0}' is not valid.";
public static readonly string FieldLengthExceeded = "'{0}' length exceeded. Maximum length allowed is '{1}'.";
public static readonly string BaseUrlIsNotValid = "The {0} must be a valid URL and end in a slash.";
public static readonly string ApiDependencyNotFound = "Failed to get a valid apiDependency from the provided apiManifestDocument. The property is required generate a complete {0}.";
public static readonly string ApiDescriptionUrlNotFound = "ApiDescriptionUrl is missing in the provided apiManifestDocument. The property is required generate a complete {0}.";
}
}
16 changes: 16 additions & 0 deletions src/lib/Exceptions/ApiManifestException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

namespace Microsoft.OpenApi.ApiManifest.Exceptions
{
public class ApiManifestException : Exception
{
public ApiManifestException(string message) : base(message)
{
}

public ApiManifestException(string message, Exception innerException) : base(message, innerException)
{
}
}
}
37 changes: 37 additions & 0 deletions src/lib/Helpers/ParsingHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using Microsoft.OpenApi.Readers;
using System.Diagnostics;
using System.Net;
using System.Text.Json;

namespace Microsoft.OpenApi.ApiManifest.Helpers;
Expand Down Expand Up @@ -136,6 +138,41 @@ internal static IEnumerable<KeyValuePair<string, string>> ParseKey(string key)
yield return keyValue;
}
}

internal static async Task<ReadResult> ParseOpenApiAsync(string openApiFileUrl, bool inlineExternal, CancellationToken cancellationToken)
{
Stream stream = await GetStreamAsync(openApiFileUrl, cancellationToken);
ReadResult result = await new OpenApiStreamReader(new OpenApiReaderSettings
{
LoadExternalRefs = inlineExternal,
BaseUrl = new Uri(openApiFileUrl)
}
).ReadAsync(stream, cancellationToken);

return result;
}

private static async Task<Stream> GetStreamAsync(string input, CancellationToken cancellationToken = default)
{
if (!input.StartsWith("http"))
throw new ArgumentException($"The input {input} is not a valid url", nameof(input));
try
{
var httpClientHandler = new HttpClientHandler()
{
SslProtocols = System.Security.Authentication.SslProtocols.Tls12,
};
using var httpClient = new HttpClient(httpClientHandler)
{
DefaultRequestVersion = HttpVersion.Version20
};
return await httpClient.GetStreamAsync(input, cancellationToken);
}
catch (HttpRequestException ex)
{
throw new InvalidOperationException($"Could not download the file at {input}", ex);
}
}
}

internal class FixedFieldMap<T> : Dictionary<string, Action<T, JsonElement>>
Expand Down
14 changes: 6 additions & 8 deletions src/lib/Publisher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,20 @@ public class Publisher

public Publisher(string name, string contactEmail)
{
Validate(name, contactEmail);

Name = name;
ContactEmail = contactEmail;
Validate();
}
private Publisher(JsonElement value)
{
ParsingHelpers.ParseMap(value, this, handlers);
Validate(Name, ContactEmail);
Validate();
}

// Write method
public void Write(Utf8JsonWriter writer)
{
Validate(Name, ContactEmail);

Validate();
writer.WriteStartObject();
writer.WriteString(NameProperty, Name);
writer.WriteString(ContactEmailProperty, ContactEmail);
Expand All @@ -41,10 +39,10 @@ internal static Publisher Load(JsonElement value)
return new Publisher(value);
}

private static void Validate(string? name, string? contactEmail)
internal void Validate()
{
ValidationHelpers.ValidateNullOrWhitespace(nameof(name), name, nameof(Publisher));
ValidationHelpers.ValidateEmail(nameof(contactEmail), contactEmail, nameof(Publisher));
ValidationHelpers.ValidateNullOrWhitespace(nameof(Name), Name, nameof(Publisher));
ValidationHelpers.ValidateEmail(nameof(ContactEmail), ContactEmail, nameof(Publisher));
}

private static readonly FixedFieldMap<Publisher> handlers = new()
Expand Down
69 changes: 69 additions & 0 deletions src/lib/TypeExtensions/ApiManifestDocumentExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Microsoft.OpenApi.ApiManifest.Exceptions;
using Microsoft.OpenApi.ApiManifest.Helpers;
using Microsoft.OpenApi.ApiManifest.OpenAI;
using Microsoft.OpenApi.ApiManifest.OpenAI.Authentication;
using Microsoft.OpenApi.Models;

namespace Microsoft.OpenApi.ApiManifest.TypeExtensions
{
public static class ApiManifestDocumentExtensions
{
/// <summary>
/// Generates an OpenAIPluginManifest from the provided ApiManifestDocument.
/// </summary>
/// <param name="apiManifestDocument">A valid instance of <see cref="ApiManifestDocument"/> to generate an OpenAI Plugin manifest from.</param>
/// <param name="logoUrl"> The URL to a logo for the plugin.</param>
/// <param name="legalInfoUrl">The URL to a page with legal information about the plugin.</param>
/// <param name="apiDependencyName">The name of apiDependency to use from the provided <see cref="ApiManifestDocument.ApiDependencies"/>. The method defaults to the first apiDependency in <see cref="ApiManifestDocument.ApiDependencies"/> if no value is provided.</param>
/// <param name="openApiFilePath">The relative path to where the OpenAPI file that's packaged with the plugin manifest if stored. The method default './openapi.json' if none is provided.</param>
/// <param name="cancellationToken">Propagates notification that operations should be canceled.</param>
/// <returns>A <see cref="Task{OpenAIPluginManifest}"/></returns>
public static async Task<OpenAIPluginManifest> ToOpenAIPluginManifestAsync(this ApiManifestDocument apiManifestDocument, string logoUrl, string legalInfoUrl, string? apiDependencyName = default, string openApiFilePath = "./openapi.json", CancellationToken cancellationToken = default)
{
if (!TryGetApiDependency(apiManifestDocument.ApiDependencies, apiDependencyName, out ApiDependency? apiDependency))
{
throw new ApiManifestException(string.Format(ErrorMessage.ApiDependencyNotFound, nameof(OpenAIPluginManifest)));
}
else if (string.IsNullOrWhiteSpace(apiDependency?.ApiDescriptionUrl))
{
throw new ApiManifestException(string.Format(ErrorMessage.ApiDescriptionUrlNotFound, nameof(OpenAIPluginManifest)));
}
else
{
var result = await ParsingHelpers.ParseOpenApiAsync(apiDependency.ApiDescriptionUrl, false, cancellationToken);
return apiManifestDocument.ToOpenAIPluginManifest(openApiDocument: result.OpenApiDocument, logoUrl: logoUrl, legalInfoUrl: legalInfoUrl, openApiFilePath: openApiFilePath);
}
}

internal static OpenAIPluginManifest ToOpenAIPluginManifest(this ApiManifestDocument apiManifestDocument, OpenApiDocument openApiDocument, string logoUrl, string legalInfoUrl, string openApiFilePath)
{
// Validate the ApiManifestDocument before generating the OpenAI manifest.
apiManifestDocument.Validate();
string contactEmail = string.IsNullOrWhiteSpace(apiManifestDocument.Publisher?.ContactEmail) ? string.Empty : apiManifestDocument.Publisher.ContactEmail;

var openApiManifest = OpenApiPluginFactory.CreateOpenAIPluginManifest(
schemaVersion: openApiDocument.Info.Version,
nameForHuman: openApiDocument.Info.Title,
nameForModel: openApiDocument.Info.Title,
logoUrl: logoUrl,
contactEmail: contactEmail,
legalInfoUrl: legalInfoUrl);

openApiManifest.Api = new Api("openapi", openApiFilePath);
openApiManifest.Auth = new ManifestNoAuth();
openApiManifest.DescriptionForHuman = openApiDocument.Info.Description ?? $"Description for {openApiManifest.NameForHuman}.";
openApiManifest.DescriptionForModel = openApiManifest.DescriptionForHuman;

return openApiManifest;
}

private static bool TryGetApiDependency(ApiDependencies apiDependencies, string? apiDependencyName, out ApiDependency? apiDependency)
{
if (apiDependencyName == default)
apiDependency = apiDependencies.FirstOrDefault().Value;
else
_ = apiDependencies.TryGetValue(apiDependencyName, out apiDependency);
return apiDependency != null;
}
}
}
4 changes: 4 additions & 0 deletions src/lib/apimanifest.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@
<AssemblyOriginatorKeyFile>..\sgKey.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.OpenApi.Readers" Version="1.6.9" />
</ItemGroup>

</Project>
6 changes: 6 additions & 0 deletions tests/ApiManifest.Tests/ApiManifest.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,10 @@
<ProjectReference Include="..\..\src\lib\apimanifest.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="TestFiles\exampleApiManifest.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
23 changes: 23 additions & 0 deletions tests/ApiManifest.Tests/Helpers/ParsingHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,28 @@ public void ParseKeyValuePair()
var kvPairs = ParsingHelpers.ParseKey(exampleKeyValuePair);
Assert.Equal(3, kvPairs.Count());
}

[Fact]
public async Task ParseOpenApiAsync()
{
var openApiUrl = "https://raw.githubusercontent.com/APIPatterns/Moostodon/main/spec/tsp-output/%40typespec/openapi3/openapi.yaml";
var results = await ParsingHelpers.ParseOpenApiAsync(openApiUrl, false, CancellationToken.None);
Assert.Empty(results.OpenApiDiagnostic.Errors);
Assert.NotNull(results.OpenApiDocument);
}

[Fact]
public void ParseOpenApiWithWrongOpenApiUrl()
{
var openApiUrl = "https://contoso.com/APIPatterns/Contoso/main/spec/tsp-output/%40typespec/openapi3/openapi.yaml";
_ = Assert.ThrowsAsync<InvalidOperationException>(async () => await ParsingHelpers.ParseOpenApiAsync(openApiUrl, false, CancellationToken.None));
}

[Fact]
public void ParseOpenApiWithOpenApiUrlWithAnInvalidSchema()
{
var openApiUrl = "contoso.com/APIPatterns/Contoso/main/spec/tsp-output/%40typespec/openapi3/openapi.yaml";
_ = Assert.ThrowsAsync<ArgumentException>(async () => await ParsingHelpers.ParseOpenApiAsync(openApiUrl, false, CancellationToken.None));
}
}
}
Loading