diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index dd84375..d47a3a4 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -71,7 +71,7 @@ jobs: CoverletOutputFormat: "opencover" # https://github.com/microsoft/vstest/issues/4014#issuecomment-1307913682 shell: pwsh run: | - ./.sonar/scanner/dotnet-sonarscanner begin /k:"microsoft_OpenApi.ApiManifest" /o:"microsoft" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="src/tests/**/coverage.opencover.xml" + ./.sonar/scanner/dotnet-sonarscanner begin /k:"microsoft_OpenApi.ApiManifest" /o:"microsoft" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="tests/**/coverage.opencover.xml" dotnet build dotnet test apimanifest.sln --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover ./.sonar/scanner/dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" diff --git a/apimanifest.sln b/apimanifest.sln index cdce9f7..d1e07fe 100644 --- a/apimanifest.sln +++ b/apimanifest.sln @@ -5,27 +5,22 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{39495B7F-9E1F-4DBE-AAA1-C9C9620675AA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tests", "src\tests\tests.csproj", "{02EFB22C-FF50-4D4C-8F83-A394597E11E6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "apimanifest", "src\lib\apimanifest.csproj", "{3B4ACF87-6364-48A2-94B8-0EB3201D922E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "apimanifest", "src\lib\apimanifest.csproj", "{3B4ACF87-6364-48A2-94B8-0EB3201D922E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "tool", "src\tool\tool.csproj", "{DCFFC5B9-253A-4BFE-9CBE-0DAAE822E3EB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tool", "src\tool\tool.csproj", "{DCFFC5B9-253A-4BFE-9CBE-0DAAE822E3EB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "benchmark", "src\benchmark\benchmark.csproj", "{24B7722C-4FE9-4B52-9AA3-5D2FBCDA2DFA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "benchmark", "src\benchmark\benchmark.csproj", "{24B7722C-4FE9-4B52-9AA3-5D2FBCDA2DFA}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{13E6B8EB-7EA6-4CAD-A9A2-3473307EB30F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiManifest.Tests", "tests\ApiManifest.Tests\ApiManifest.Tests.csproj", "{10411C2B-C1AC-44FC-AF46-E0264438E797}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {02EFB22C-FF50-4D4C-8F83-A394597E11E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {02EFB22C-FF50-4D4C-8F83-A394597E11E6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {02EFB22C-FF50-4D4C-8F83-A394597E11E6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {02EFB22C-FF50-4D4C-8F83-A394597E11E6}.Release|Any CPU.Build.0 = Release|Any CPU {3B4ACF87-6364-48A2-94B8-0EB3201D922E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3B4ACF87-6364-48A2-94B8-0EB3201D922E}.Debug|Any CPU.Build.0 = Debug|Any CPU {3B4ACF87-6364-48A2-94B8-0EB3201D922E}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -38,11 +33,21 @@ Global {24B7722C-4FE9-4B52-9AA3-5D2FBCDA2DFA}.Debug|Any CPU.Build.0 = Debug|Any CPU {24B7722C-4FE9-4B52-9AA3-5D2FBCDA2DFA}.Release|Any CPU.ActiveCfg = Release|Any CPU {24B7722C-4FE9-4B52-9AA3-5D2FBCDA2DFA}.Release|Any CPU.Build.0 = Release|Any CPU + {10411C2B-C1AC-44FC-AF46-E0264438E797}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10411C2B-C1AC-44FC-AF46-E0264438E797}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10411C2B-C1AC-44FC-AF46-E0264438E797}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10411C2B-C1AC-44FC-AF46-E0264438E797}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {02EFB22C-FF50-4D4C-8F83-A394597E11E6} = {39495B7F-9E1F-4DBE-AAA1-C9C9620675AA} {3B4ACF87-6364-48A2-94B8-0EB3201D922E} = {39495B7F-9E1F-4DBE-AAA1-C9C9620675AA} {DCFFC5B9-253A-4BFE-9CBE-0DAAE822E3EB} = {39495B7F-9E1F-4DBE-AAA1-C9C9620675AA} {24B7722C-4FE9-4B52-9AA3-5D2FBCDA2DFA} = {39495B7F-9E1F-4DBE-AAA1-C9C9620675AA} + {10411C2B-C1AC-44FC-AF46-E0264438E797} = {13E6B8EB-7EA6-4CAD-A9A2-3473307EB30F} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6A91121B-DC65-413C-8635-B32B30C30A6F} EndGlobalSection EndGlobal diff --git a/src/lib/AccessRequest.cs b/src/lib/AccessRequest.cs index edb3796..90d0aec 100644 --- a/src/lib/AccessRequest.cs +++ b/src/lib/AccessRequest.cs @@ -1,3 +1,4 @@ +using Microsoft.OpenApi.ApiManifest.Helpers; using System.Text.Json; using System.Text.Json.Nodes; @@ -5,9 +6,7 @@ namespace Microsoft.OpenApi.ApiManifest; public class AccessRequest { - // TODO: Add validation. Type is required and is unique for the described API according to RAR - https://www.rfc-editor.org/rfc/rfc9396. private const string TypeProperty = "type"; - // TODO: Rename to 'actions' to match RAR spec. private const string ContentProperty = "content"; public string? Type { get; set; } diff --git a/src/lib/ApiDependency.cs b/src/lib/ApiDependency.cs index cb461ff..c36e85d 100644 --- a/src/lib/ApiDependency.cs +++ b/src/lib/ApiDependency.cs @@ -1,3 +1,4 @@ +using Microsoft.OpenApi.ApiManifest.Helpers; using System.Text.Json; namespace Microsoft.OpenApi.ApiManifest; @@ -6,11 +7,12 @@ public class ApiDependency public string? ApiDescriptionUrl { get; set; } public string? ApiDescriptionVersion { get; set; } private string? _apiDeploymentBaseUrl; - public string? ApiDeploymentBaseUrl { + public string? ApiDeploymentBaseUrl + { get { return _apiDeploymentBaseUrl; } set { - ValidateApiDeploymentBaseUrl(value); + ValidationHelpers.ValidateBaseUrl(nameof(ApiDeploymentBaseUrl), value); _apiDeploymentBaseUrl = value; } } @@ -69,15 +71,6 @@ internal static ApiDependency Load(JsonElement value) return apiDependency; } - private static void ValidateApiDeploymentBaseUrl(string? apiDeploymentBaseUrl) - { - // Check if the apiDeploymentBaseUrl is a valid URL and ends in a slash. - if (apiDeploymentBaseUrl == null || !apiDeploymentBaseUrl.EndsWith("/", StringComparison.Ordinal) || !Uri.TryCreate(apiDeploymentBaseUrl, UriKind.Absolute, out _)) - { - throw new ArgumentException($"The {nameof(apiDeploymentBaseUrl)} must be a valid URL and end in a slash."); - } - } - // Fixed fieldmap for ApiDependency private static readonly FixedFieldMap handlers = new() { diff --git a/src/lib/ApiManifestDocument.cs b/src/lib/ApiManifestDocument.cs index 0e6d59f..7f2fd5e 100644 --- a/src/lib/ApiManifestDocument.cs +++ b/src/lib/ApiManifestDocument.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using Microsoft.OpenApi.ApiManifest.Helpers; +using System.Text.Json; namespace Microsoft.OpenApi.ApiManifest; @@ -72,8 +73,7 @@ public static ApiManifestDocument Load(JsonElement value) private static void Validate(string? applicationName) { - if (string.IsNullOrWhiteSpace(applicationName)) - throw new ArgumentNullException(applicationName, String.Format(ErrorMessage.FieldIsRequired, "applicationName", "ApiManifest")); + ValidationHelpers.ValidateNullOrWhitespace(nameof(applicationName), applicationName, nameof(ApiManifestDocument)); } // Create fixed field map for ApiManifest diff --git a/src/lib/AuthorizationRequirements.cs b/src/lib/AuthorizationRequirements.cs index 1acfff6..d6c3281 100644 --- a/src/lib/AuthorizationRequirements.cs +++ b/src/lib/AuthorizationRequirements.cs @@ -1,3 +1,4 @@ +using Microsoft.OpenApi.ApiManifest.Helpers; using System.Text.Json; namespace Microsoft.OpenApi.ApiManifest; @@ -5,7 +6,6 @@ namespace Microsoft.OpenApi.ApiManifest; public class AuthorizationRequirements { public string? ClientIdentifier { get; set; } - // TODO: Confirm the need for AccessReference property. It is not present in the spec. public List? AccessReference { get; set; } public List? Access { get; set; } diff --git a/src/lib/Constants.cs b/src/lib/Constants.cs index 4cdd4dc..b46de2a 100644 --- a/src/lib/Constants.cs +++ b/src/lib/Constants.cs @@ -1,4 +1,7 @@ -namespace Microsoft.OpenApi.ApiManifest +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.OpenApi.ApiManifest { internal static class Constants { @@ -7,7 +10,9 @@ internal static class Constants internal static class ErrorMessage { - public static string FieldIsRequired = "'{0}' is a required property of '{1}'."; - public static string FieldIsNotValid = "'{0}' is not valid."; + public static readonly string FieldIsRequired = "'{0}' is a required property of '{1}'."; + 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."; } } \ No newline at end of file diff --git a/src/lib/Extensions.cs b/src/lib/Extensions.cs index a5916c9..b6402bf 100644 --- a/src/lib/Extensions.cs +++ b/src/lib/Extensions.cs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + using System.Text.Json; using System.Text.Json.Nodes; @@ -14,7 +17,7 @@ public static Extensions Load(JsonElement value) { if (property.Value.ValueKind != JsonValueKind.Null) { - var extensionValue = JsonSerializer.Deserialize(property.Value.GetRawText()); + var extensionValue = JsonSerializer.Deserialize(property.Value.GetRawText()); extensions.Add(property.Name, extensionValue); } } diff --git a/src/lib/ParsingHelpers.cs b/src/lib/Helpers/ParsingHelpers.cs similarity index 76% rename from src/lib/ParsingHelpers.cs rename to src/lib/Helpers/ParsingHelpers.cs index c523ca5..4bddecd 100644 --- a/src/lib/ParsingHelpers.cs +++ b/src/lib/Helpers/ParsingHelpers.cs @@ -1,10 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Diagnostics; using System.Text.Json; -namespace Microsoft.OpenApi.ApiManifest; +namespace Microsoft.OpenApi.ApiManifest.Helpers; -internal class ParsingHelpers +internal static class ParsingHelpers { - public static void ParseMap(JsonElement node, T permissionsDocument, FixedFieldMap handlers) + internal static void ParseMap(JsonElement node, T permissionsDocument, FixedFieldMap handlers) { foreach (var element in node.EnumerateObject()) { @@ -12,8 +16,12 @@ public static void ParseMap(JsonElement node, T permissionsDocument, FixedFie { handler(permissionsDocument, element.Value); } - //TODO we should log the unknown property or use an additional properties model - }; + else + { + // Logs the unknown property. We can switch to additional properties model in the future if need be. + Debug.WriteLine($"Skipped {element.Name}. The property is unknown."); + } + } } internal static List GetList(JsonElement v, Func load) @@ -46,6 +54,17 @@ internal static SortedDictionary GetOrderedMap(JsonElement v, Func return map; } + internal static Dictionary GetMapOfString(JsonElement v) + { + var map = new Dictionary(); + foreach (var item in v.EnumerateObject()) + { + var value = item.Value.GetString(); + map.Add(item.Name, string.IsNullOrWhiteSpace(value) ? string.Empty : value); + } + return map; + } + internal static List GetListOfString(JsonElement v) { var list = new List(); @@ -107,23 +126,19 @@ internal static IEnumerable> ParseKey(string key) foreach (var pair in key.Split(';')) { if (string.IsNullOrEmpty(pair)) - { continue; - } var index = pair.IndexOf('='); if (index == -1) - { throw new InvalidOperationException($"Unable to parse: {key}. Format is name1=value1;name2=value2;..."); - } - var keyValue = new KeyValuePair(pair.Substring(0, index), pair.Substring(index + 1)); + var keyValue = new KeyValuePair(pair[..index], pair[(index + 1)..]); yield return keyValue; } } } -public class FixedFieldMap : Dictionary> +internal class FixedFieldMap : Dictionary> { public FixedFieldMap() : base(StringComparer.OrdinalIgnoreCase) { diff --git a/src/lib/Helpers/ValidationHelpers.cs b/src/lib/Helpers/ValidationHelpers.cs new file mode 100644 index 0000000..0e193dc --- /dev/null +++ b/src/lib/Helpers/ValidationHelpers.cs @@ -0,0 +1,41 @@ +using System.Text.RegularExpressions; + +namespace Microsoft.OpenApi.ApiManifest.Helpers +{ + internal static class ValidationHelpers + { + internal static void ValidateNullOrWhitespace(string parameterName, string? value, string parentName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentNullException(parameterName, string.Format(ErrorMessage.FieldIsRequired, parameterName, parentName)); + } + + internal static void ValidateLength(string parameterName, string? value, int maxLength) + { + if (value?.Length > maxLength) + throw new ArgumentOutOfRangeException(parameterName, string.Format(ErrorMessage.FieldLengthExceeded, parameterName, maxLength)); + } + + internal static void ValidateEmail(string parameterName, string? value, string parentName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentNullException(parameterName, string.Format(ErrorMessage.FieldIsRequired, parameterName, parentName)); + else + ValidateEmail(parameterName, value); + } + + internal static void ValidateBaseUrl(string parameterName, string? baseUrl) + { + // Check if the baseUrl is a valid URL and ends in a slash. + if (string.IsNullOrWhiteSpace(baseUrl) || !baseUrl.EndsWith("/", StringComparison.Ordinal) || !Uri.TryCreate(baseUrl, UriKind.Absolute, out _)) + throw new ArgumentException(string.Format(ErrorMessage.BaseUrlIsNotValid, nameof(baseUrl)), parameterName); + } + + private static readonly Regex s_emailRegex = new(@"^[^@\s]+@[^@\s]+$", RegexOptions.Compiled, Constants.DefaultRegexTimeout); + private static void ValidateEmail(string parameterName, string value) + { + if (!s_emailRegex.IsMatch(value)) + throw new ArgumentException(string.Format(ErrorMessage.FieldIsNotValid, parameterName), parameterName); + } + } +} diff --git a/src/lib/OpenAI/Api.cs b/src/lib/OpenAI/Api.cs index 0560189..3f48184 100644 --- a/src/lib/OpenAI/Api.cs +++ b/src/lib/OpenAI/Api.cs @@ -1,17 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.Helpers; using System.Text.Json; namespace Microsoft.OpenApi.ApiManifest.OpenAI; public class Api { + private const string TypeProperty = "type"; + private const string UrlProperty = "url"; + private const string IsUserAuthenticatedProperty = "is_user_authenticated"; public string? Type { get; set; } public string? Url { get; set; } public bool? IsUserAuthenticated { get; set; } + public Api(string type, string url) + { + Type = type; + Url = url; + Validate(this); + } + + internal Api(JsonElement value) + { + ParsingHelpers.ParseMap(value, this, handlers); + Validate(this); + } + public static Api Load(JsonElement value) { - var api = new Api(); + var api = new Api(value); ParsingHelpers.ParseMap(value, api, handlers); return api; } @@ -19,19 +39,26 @@ public static Api Load(JsonElement value) // Create handlers FixedFieldMap for Api private static readonly FixedFieldMap handlers = new() { - { "type", (o,v) => {o.Type = v.GetString(); } }, - { "url", (o,v) => {o.Url = v.GetString(); } }, - { "is_user_authenticated", (o,v) => {o.IsUserAuthenticated = v.GetBoolean(); }}, + { TypeProperty, (o,v) => {o.Type = v.GetString(); } }, + { UrlProperty, (o,v) => {o.Url = v.GetString(); } }, + { IsUserAuthenticatedProperty, (o,v) => {o.IsUserAuthenticated = v.GetBoolean(); }}, }; public void Write(Utf8JsonWriter writer) { + Validate(this); writer.WriteStartObject(); - writer.WriteString("type", Type); - writer.WriteString("url", Url); - writer.WriteBoolean("is_user_authenticated", IsUserAuthenticated ?? false); + writer.WriteString(TypeProperty, Type); + writer.WriteString(UrlProperty, Url); + writer.WriteBoolean(IsUserAuthenticatedProperty, IsUserAuthenticated ?? false); writer.WriteEndObject(); } + + private void Validate(Api api) + { + ValidationHelpers.ValidateNullOrWhitespace(nameof(Type), api.Type, nameof(Api)); + ValidationHelpers.ValidateNullOrWhitespace(nameof(Url), api.Url, nameof(Api)); + } } diff --git a/src/lib/OpenAI/Authentication/BaseManifestAuth.cs b/src/lib/OpenAI/Authentication/BaseManifestAuth.cs new file mode 100644 index 0000000..d8dd841 --- /dev/null +++ b/src/lib/OpenAI/Authentication/BaseManifestAuth.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.Helpers; +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.OpenAI.Authentication; + +public abstract class BaseManifestAuth +{ + private const string TypeProperty = "type"; + private const string InstructionsProperty = "instructions"; + + public string? Type { get; internal set; } + public string? Instructions { get; set; } + + // Create handlers FixedFieldMap for BaseManifestAuth properties. + private static readonly FixedFieldMap handlers = new() + { + { TypeProperty, (o,v) => {o.Type = v.GetString(); } }, + { InstructionsProperty, (o,v) => {o.Instructions = v.GetString(); } } + }; + + /// + /// Loads the common properties for all authentication types. + /// + /// The to parse. + protected void LoadProperties(JsonElement value) + { + ParsingHelpers.ParseMap(value, this, handlers); + } + + /// + /// Write the common properties for all authentication types. This method does not write the opening and closing object tags. + /// + /// The to use. + protected void WriteProperties(Utf8JsonWriter writer) + { + writer.WriteString(TypeProperty, Type); + if (!string.IsNullOrWhiteSpace(Instructions)) writer.WriteString(InstructionsProperty, Instructions); + } + + public virtual void Write(Utf8JsonWriter writer) { } +} diff --git a/src/lib/OpenAI/Authentication/ManifestAuthFactory.cs b/src/lib/OpenAI/Authentication/ManifestAuthFactory.cs new file mode 100644 index 0000000..a2823ae --- /dev/null +++ b/src/lib/OpenAI/Authentication/ManifestAuthFactory.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.OpenAI.Authentication +{ + internal static class ManifestAuthFactory + { + public static BaseManifestAuth CreateManifestAuth(JsonElement value) + { + var authType = value.GetProperty("type").GetString()?.ToLowerInvariant(); + return authType switch + { + "none" => ManifestNoAuth.Load(value), + "user_http" => ManifestUserHttpAuth.Load(value), + "service_http" => ManifestServiceHttpAuth.Load(value), + "oauth" => ManifestOAuthAuth.Load(value), + _ => throw new ArgumentOutOfRangeException(nameof(value), $"Unknown auth type: {authType}") + }; + } + } +} diff --git a/src/lib/OpenAI/Authentication/ManifestNoAuth.cs b/src/lib/OpenAI/Authentication/ManifestNoAuth.cs new file mode 100644 index 0000000..ede769c --- /dev/null +++ b/src/lib/OpenAI/Authentication/ManifestNoAuth.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.OpenAI.Authentication; + +public class ManifestNoAuth : BaseManifestAuth +{ + public ManifestNoAuth() + { + Type = "none"; + } + + public static ManifestNoAuth Load(JsonElement value) + { + var auth = new ManifestNoAuth(); + auth.LoadProperties(value); + return auth; + } + + public override void Write(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + WriteProperties(writer); + writer.WriteEndObject(); + } +} diff --git a/src/lib/OpenAI/Authentication/ManifestOAuthAuth.cs b/src/lib/OpenAI/Authentication/ManifestOAuthAuth.cs new file mode 100644 index 0000000..b7bc61f --- /dev/null +++ b/src/lib/OpenAI/Authentication/ManifestOAuthAuth.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.Helpers; +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.OpenAI.Authentication; + +public class ManifestOAuthAuth : BaseManifestAuth +{ + private const string ClientUrlPropertyName = "client_url"; + private const string ScopePropertyName = "scope"; + private const string AuthorizationUrlPropertyName = "authorization_url"; + private const string AuthorizationContentTypePropertyName = "authorization_content_type"; + private const string VerificationTokensPropertyName = "verification_tokens"; + + public string? ClientUrl { get; set; } + public string? Scope { get; set; } + public string? AuthorizationUrl { get; set; } + public string? AuthorizationContentType { get; set; } + public VerificationTokens VerificationTokens { get; set; } = new VerificationTokens(); + + public ManifestOAuthAuth() + { + Type = "oauth"; + } + + private static readonly FixedFieldMap handlers = new() + { + { ClientUrlPropertyName, (o,v) => {o.ClientUrl = v.GetString(); } }, + { ScopePropertyName, (o,v) => {o.Scope = v.GetString(); } }, + { AuthorizationUrlPropertyName, (o,v) => {o.AuthorizationUrl = v.GetString(); } }, + { AuthorizationContentTypePropertyName, (o,v) => {o.AuthorizationContentType = v.GetString(); } }, + { VerificationTokensPropertyName, (o,v) => { o.VerificationTokens = VerificationTokens.Load(v); } }, + }; + + public static ManifestOAuthAuth Load(JsonElement value) + { + var auth = new ManifestOAuthAuth(); + auth.LoadProperties(value); + ParsingHelpers.ParseMap(value, auth, handlers); + return auth; + } + + public override void Write(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + WriteProperties(writer); + if (!string.IsNullOrWhiteSpace(ClientUrl)) writer.WriteString(ClientUrlPropertyName, ClientUrl); + if (!string.IsNullOrWhiteSpace(Scope)) writer.WriteString(ScopePropertyName, Scope); + if (!string.IsNullOrWhiteSpace(AuthorizationUrl)) writer.WriteString(AuthorizationUrlPropertyName, AuthorizationUrl); + if (!string.IsNullOrWhiteSpace(AuthorizationContentType)) writer.WriteString(AuthorizationContentTypePropertyName, AuthorizationContentType); + if (VerificationTokens.Any()) + { + writer.WritePropertyName(VerificationTokensPropertyName); + VerificationTokens.Write(writer); + } + writer.WriteEndObject(); + } +} diff --git a/src/lib/OpenAI/Authentication/ManifestServiceHttpAuth.cs b/src/lib/OpenAI/Authentication/ManifestServiceHttpAuth.cs new file mode 100644 index 0000000..c5e5652 --- /dev/null +++ b/src/lib/OpenAI/Authentication/ManifestServiceHttpAuth.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.Helpers; +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.OpenAI.Authentication; + +public class ManifestServiceHttpAuth : BaseManifestAuth +{ + private const string AuthorizationTypeProperty = "authorization_type"; + private const string VerificationTokensProperty = "verification_tokens"; + public string? AuthorizationType { get; set; } + public VerificationTokens VerificationTokens { get; set; } + public ManifestServiceHttpAuth(VerificationTokens verificationTokens) + { + if (verificationTokens == null || !verificationTokens.Any()) + { + // Reference: https://platform.openai.com/docs/plugins/authentication/service-level + throw new ArgumentException($"{nameof(verificationTokens)} must be have at least one verification token."); + } + Type = "service_http"; + AuthorizationType = "bearer"; + VerificationTokens = verificationTokens; + } + + private static readonly FixedFieldMap handlers = new() + { + { AuthorizationTypeProperty, (o,v) => {o.AuthorizationType = v.GetString(); } } + }; + + public static ManifestServiceHttpAuth Load(JsonElement value) + { + var auth = new ManifestServiceHttpAuth(VerificationTokens.Load(value.GetProperty(VerificationTokensProperty))); + auth.LoadProperties(value); + ParsingHelpers.ParseMap(value, auth, handlers); + return auth; + } + + public override void Write(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + WriteProperties(writer); + writer.WriteString(AuthorizationTypeProperty, AuthorizationType); + writer.WritePropertyName(VerificationTokensProperty); + VerificationTokens.Write(writer); + writer.WriteEndObject(); + } +} diff --git a/src/lib/OpenAI/Authentication/ManifestUserHttpAuth.cs b/src/lib/OpenAI/Authentication/ManifestUserHttpAuth.cs new file mode 100644 index 0000000..5435dab --- /dev/null +++ b/src/lib/OpenAI/Authentication/ManifestUserHttpAuth.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.OpenAI.Authentication; + +public class ManifestUserHttpAuth : BaseManifestAuth +{ + private const string AuthorizationTypeProperty = "authorization_type"; + public string? AuthorizationType { get; set; } + public ManifestUserHttpAuth(string? authorizationType) + { + if (string.IsNullOrWhiteSpace(authorizationType) || + (!string.Equals(authorizationType, "basic", StringComparison.OrdinalIgnoreCase) && !string.Equals(authorizationType, "bearer", StringComparison.OrdinalIgnoreCase))) + { + // Reference: https://platform.openai.com/docs/plugins/authentication/user-level + throw new ArgumentException($"{nameof(authorizationType)} must be either 'basic' or 'bearer'."); + } + Type = "user_http"; + AuthorizationType = authorizationType; + } + + public static ManifestUserHttpAuth Load(JsonElement value) + { + var auth = new ManifestUserHttpAuth(value.GetProperty(AuthorizationTypeProperty).GetString()); + auth.LoadProperties(value); + return auth; + } + + public override void Write(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + WriteProperties(writer); + writer.WriteString(AuthorizationTypeProperty, AuthorizationType); + writer.WriteEndObject(); + } +} diff --git a/src/lib/OpenAI/Authentication/VerificationTokens.cs b/src/lib/OpenAI/Authentication/VerificationTokens.cs new file mode 100644 index 0000000..4e74de7 --- /dev/null +++ b/src/lib/OpenAI/Authentication/VerificationTokens.cs @@ -0,0 +1,29 @@ + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.Helpers; +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.OpenAI.Authentication; + +public class VerificationTokens : Dictionary +{ + public VerificationTokens(IDictionary dictionary) : base(dictionary, StringComparer.OrdinalIgnoreCase) { } + public VerificationTokens() : base(StringComparer.OrdinalIgnoreCase) { } + + internal static VerificationTokens Load(JsonElement value) + { + return new VerificationTokens(ParsingHelpers.GetMapOfString(value)); + } + + public void Write(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + foreach (var verificationToken in this) + { + writer.WriteString(verificationToken.Key, verificationToken.Value); + } + writer.WriteEndObject(); + } +} diff --git a/src/lib/OpenAI/BaseManifestAuth.cs b/src/lib/OpenAI/BaseManifestAuth.cs deleted file mode 100644 index f73d9cf..0000000 --- a/src/lib/OpenAI/BaseManifestAuth.cs +++ /dev/null @@ -1,145 +0,0 @@ - -using System.Text.Json; - -namespace Microsoft.OpenApi.ApiManifest.OpenAI; - -public abstract class BaseManifestAuth -{ - public string? Type { get; set; } - public string? Instructions { get; set; } - - public static BaseManifestAuth? Load(JsonElement value) - { - BaseManifestAuth? auth = null; - - switch (value.GetProperty("type").GetString()) - { - case "none": - auth = new ManifestNoAuth(); - ParsingHelpers.ParseMap(value, (ManifestNoAuth)auth, ManifestNoAuth.handlers); - break; - case "user_http": - auth = new ManifestUserHttpAuth(); - ParsingHelpers.ParseMap(value, (ManifestUserHttpAuth)auth, ManifestUserHttpAuth.handlers); - break; - case "service_http": - auth = new ManifestServiceHttpAuth(); - ParsingHelpers.ParseMap(value, (ManifestServiceHttpAuth)auth, ManifestServiceHttpAuth.handlers); - break; - case "oauth": - auth = new ManifestOAuthAuth(); - ParsingHelpers.ParseMap(value, (ManifestOAuthAuth)auth, ManifestOAuthAuth.handlers); - break; - } - - return auth; - } - - // Create handlers FixedFieldMap for ManifestAuth - - public virtual void Write(Utf8JsonWriter writer) { } - -} - -public class ManifestNoAuth : BaseManifestAuth -{ - public ManifestNoAuth() - { - Type = "none"; - } - - internal static FixedFieldMap handlers = new() - { - { "type", (o,v) => {o.Type = v.GetString(); } }, - { "instructions", (o,v) => {o.Instructions = v.GetString(); } }, - }; - - public override void Write(Utf8JsonWriter writer) - { - writer.WriteStartObject(); - writer.WriteString("type", Type); - if (Instructions != null) writer.WriteString("instructions", Instructions); - writer.WriteEndObject(); - } -} - -public class ManifestOAuthAuth : BaseManifestAuth -{ - public string? ClientUrl { get; set; } - public string? Scope { get; set; } - public string? AuthorizationUrl { get; set; } - public string? AuthorizationContentType { get; set; } - public Dictionary? VerificationTokens { get; set; } - - public ManifestOAuthAuth() - { - Type = "oauth"; - } - internal static FixedFieldMap handlers = new() - { - { "type", (o,v) => {o.Type = v.GetString(); } }, - { "instructions", (o,v) => {o.Instructions = v.GetString(); } }, - { "client_url", (o,v) => {o.ClientUrl = v.GetString(); } }, - { "scope", (o,v) => {o.Scope = v.GetString(); } }, - { "authorization_url", (o,v) => {o.AuthorizationUrl = v.GetString(); } }, - { "authorization_content_type", (o,v) => {o.AuthorizationContentType = v.GetString(); } }, - { "verification_tokens", (o,v) => { o.VerificationTokens = ParsingHelpers.GetMap(v,(e) => e.GetString() is string val && !string.IsNullOrEmpty(val) ? val : string.Empty ); } }, - }; - - public override void Write(Utf8JsonWriter writer) - { - writer.WriteStartObject(); - writer.WriteString("type", Type); - - if (Instructions != null) writer.WriteString("instructions", Instructions); - if (ClientUrl != null) writer.WriteString("client_url", ClientUrl); - if (Scope != null) writer.WriteString("scope", Scope); - if (AuthorizationUrl != null) writer.WriteString("authorization_url", AuthorizationUrl); - if (AuthorizationContentType != null) writer.WriteString("authorization_content_type", AuthorizationContentType); - writer.WriteEndObject(); - } -} - -public class ManifestUserHttpAuth : BaseManifestAuth -{ - public ManifestUserHttpAuth() - { - Type = "user_http"; - } - internal static FixedFieldMap handlers = new() - { - { "type", (o,v) => {o.Type = v.GetString(); } }, - { "instructions", (o,v) => {o.Instructions = v.GetString(); } }, - }; - public override void Write(Utf8JsonWriter writer) - { - writer.WriteStartObject(); - writer.WriteString("type", Type); - writer.WriteString("instructions", Instructions); - writer.WriteEndObject(); - } -} - -public class ManifestServiceHttpAuth : BaseManifestAuth -{ - public ManifestServiceHttpAuth() - { - Type = "service_http"; - } - internal static FixedFieldMap handlers = new() - { - { "type", (o,v) => {o.Type = v.GetString(); } }, - { "instructions", (o,v) => {o.Instructions = v.GetString(); } }, - }; - public override void Write(Utf8JsonWriter writer) - { - writer.WriteStartObject(); - writer.WriteString("type", Type); - writer.WriteString("instructions", Instructions); - writer.WriteEndObject(); - } -} - - - - diff --git a/src/lib/OpenAI/OpenAIPluginManifest.cs b/src/lib/OpenAI/OpenAIPluginManifest.cs index cac4465..7ab27c7 100644 --- a/src/lib/OpenAI/OpenAIPluginManifest.cs +++ b/src/lib/OpenAI/OpenAIPluginManifest.cs @@ -1,72 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.Helpers; +using Microsoft.OpenApi.ApiManifest.OpenAI.Authentication; using System.Text.Json; namespace Microsoft.OpenApi.ApiManifest.OpenAI; public class OpenAIPluginManifest { + private const string SchemaVersionProperty = "schema_version"; + private const string NameForHumanProperty = "name_for_human"; + private const string NameForModelProperty = "name_for_model"; + private const string DescriptionForHumanProperty = "description_for_human"; + private const string DescriptionForModelProperty = "description_for_model"; + private const string AuthProperty = "auth"; + private const string ApiProperty = "api"; + private const string LogoUrlProperty = "logo_url"; + private const string ContactEmailProperty = "contact_email"; + private const string LegalInfoUrlProperty = "legal_info_url"; + + /// + /// REQUIRED. The version of the manifest schema. + /// public string? SchemaVersion { get; set; } + /// + /// REQUIRED. The name of the plugin that will be shown to users. + /// public string? NameForHuman { get; set; } + /// + /// REQUIRED. The name the model will use to target the plugin. + /// public string? NameForModel { get; set; } + /// + /// REQUIRED. A description of the plugin that will be shown to users. + /// public string? DescriptionForHuman { get; set; } + /// + /// REQUIRED. Description better tailored to the model, such as token context length considerations or keyword usage for improved plugin prompting. + /// public string? DescriptionForModel { get; set; } + /// + /// REQUIRED. The authentication schema type for the plugin. This can be one of the following types: , , , and . + /// public BaseManifestAuth? Auth { get; set; } + /// + /// REQUIRED. The API specification for the plugin. + /// public Api? Api { get; set; } + /// + /// REQUIRED. A URL to a logo for the plugin. This logo will be shown to users. Suggested size: 512 x 512. Transparent backgrounds are supported. Must be an image, no GIFs are allowed. + /// public string? LogoUrl { get; set; } + /// + /// REQUIRED. An email address for safety/moderation, support, and deactivation. + /// public string? ContactEmail { get; set; } + /// + /// REQUIRED. A URL to a page with legal information about the plugin. + /// public string? LegalInfoUrl { get; set; } - public OpenAIPluginManifest() + public OpenAIPluginManifest(string nameForModel, string nameForHuman, string logoUrl, string contactEmail, string legalInfoUrl, string schemaVersion = "v1") { - SchemaVersion = "v1"; + SchemaVersion = schemaVersion; + NameForHuman = nameForHuman; + NameForModel = nameForModel; + LogoUrl = logoUrl; + ContactEmail = contactEmail; + LegalInfoUrl = legalInfoUrl; } - public static OpenAIPluginManifest Load(JsonElement value) + internal OpenAIPluginManifest(JsonElement value) { - var manifest = new OpenAIPluginManifest(); - ParsingHelpers.ParseMap(value, manifest, handlers); - return manifest; + ParsingHelpers.ParseMap(value, this, handlers); + Validate(this); } - // Create handlers FixedFieldMap for OpenAIPluginManifest - private static readonly FixedFieldMap handlers = new() + public static OpenAIPluginManifest Load(JsonElement value) { - { "schema_version", (o,v) => {o.SchemaVersion = v.GetString(); } }, - { "name_for_human", (o,v) => {o.NameForHuman = v.GetString(); } }, - { "name_for_model", (o,v) => {o.NameForModel = v.GetString(); } }, - { "description_for_human", (o,v) => {o.DescriptionForHuman = v.GetString(); } }, - { "description_for_model", (o,v) => {o.DescriptionForModel = v.GetString(); } }, - { "auth", (o,v) => {o.Auth = BaseManifestAuth.Load(v); } }, - { "api", (o,v) => {o.Api = Api.Load(v); } }, - { "logo_url", (o,v) => {o.LogoUrl = v.GetString(); } }, - { "contact_email", (o,v) => {o.ContactEmail = v.GetString(); } }, - { "legal_info_url", (o,v) => {o.LegalInfoUrl = v.GetString(); } }, - }; + return new OpenAIPluginManifest(value); + } //Write method public void Write(Utf8JsonWriter writer) { + Validate(this); writer.WriteStartObject(); - writer.WriteString("schema_version", SchemaVersion); - writer.WriteString("name_for_human", NameForHuman); - writer.WriteString("name_for_model", NameForModel); - writer.WriteString("description_for_human", DescriptionForHuman); - writer.WriteString("description_for_model", DescriptionForModel); - if (Auth != null) - { - writer.WritePropertyName("auth"); - Auth.Write(writer); - } - if (Api != null) - { - writer.WritePropertyName("api"); - Api?.Write(writer); - } - if (LogoUrl != null) writer.WriteString("logo_url", LogoUrl); - if (ContactEmail != null) writer.WriteString("contact_email", ContactEmail); - if (LegalInfoUrl != null) writer.WriteString("legal_info_url", LegalInfoUrl); + writer.WriteString(SchemaVersionProperty, SchemaVersion); + writer.WriteString(NameForHumanProperty, NameForHuman); + writer.WriteString(NameForModelProperty, NameForModel); + writer.WriteString(DescriptionForHumanProperty, DescriptionForHuman); + writer.WriteString(DescriptionForModelProperty, DescriptionForModel); + writer.WritePropertyName(AuthProperty); + Auth?.Write(writer); + writer.WritePropertyName(ApiProperty); + Api?.Write(writer); + writer.WriteString(LogoUrlProperty, LogoUrl); + writer.WriteString(ContactEmailProperty, ContactEmail); + writer.WriteString(LegalInfoUrlProperty, LegalInfoUrl); writer.WriteEndObject(); } + + // Create handlers FixedFieldMap for OpenAIPluginManifest + private static readonly FixedFieldMap handlers = new() + { + { SchemaVersionProperty, (o,v) => {o.SchemaVersion = v.GetString(); } }, + { NameForHumanProperty, (o,v) => {o.NameForHuman = v.GetString(); } }, + { NameForModelProperty, (o,v) => {o.NameForModel = v.GetString(); } }, + { DescriptionForHumanProperty, (o,v) => {o.DescriptionForHuman = v.GetString(); } }, + { DescriptionForModelProperty, (o,v) => {o.DescriptionForModel = v.GetString(); } }, + { AuthProperty, (o,v) => {o.Auth = ManifestAuthFactory.CreateManifestAuth(v); } }, + { ApiProperty, (o,v) => {o.Api = Api.Load(v); } }, + { LogoUrlProperty, (o,v) => {o.LogoUrl = v.GetString(); } }, + { ContactEmailProperty, (o,v) => {o.ContactEmail = v.GetString(); } }, + { LegalInfoUrlProperty, (o,v) => {o.LegalInfoUrl = v.GetString(); } }, + }; + + /// + /// Validate the provided based on the Open AI Plugin manifest schema at https://platform.openai.com/docs/plugins/getting-started/plugin-manifest. + /// + /// The to validate. + private void Validate(OpenAIPluginManifest openAIPluginManifest) + { + ValidationHelpers.ValidateNullOrWhitespace(nameof(NameForHuman), openAIPluginManifest.NameForHuman, nameof(OpenAIPluginManifest)); + ValidationHelpers.ValidateLength(nameof(NameForHuman), openAIPluginManifest.NameForHuman, 20); + + ValidationHelpers.ValidateNullOrWhitespace(nameof(NameForModel), openAIPluginManifest.NameForModel, nameof(OpenAIPluginManifest)); + ValidationHelpers.ValidateLength(nameof(NameForModel), openAIPluginManifest.NameForModel, 50); + + ValidationHelpers.ValidateNullOrWhitespace(nameof(DescriptionForHuman), openAIPluginManifest.DescriptionForHuman, nameof(OpenAIPluginManifest)); + ValidationHelpers.ValidateLength(nameof(DescriptionForHuman), openAIPluginManifest.DescriptionForHuman, 100); + + ValidationHelpers.ValidateNullOrWhitespace(nameof(DescriptionForModel), openAIPluginManifest.DescriptionForModel, nameof(OpenAIPluginManifest)); + ValidationHelpers.ValidateLength(nameof(DescriptionForModel), openAIPluginManifest.DescriptionForModel, 8000); + + ValidationHelpers.ValidateNullOrWhitespace(nameof(SchemaVersion), openAIPluginManifest.SchemaVersion, nameof(OpenAIPluginManifest)); + ArgumentNullException.ThrowIfNull(openAIPluginManifest.Auth); + ArgumentNullException.ThrowIfNull(openAIPluginManifest.Api); + ValidationHelpers.ValidateNullOrWhitespace(nameof(LogoUrl), openAIPluginManifest.LogoUrl, nameof(OpenAIPluginManifest)); + ValidationHelpers.ValidateEmail(nameof(ContactEmail), openAIPluginManifest.ContactEmail, nameof(OpenAIPluginManifest)); + ValidationHelpers.ValidateNullOrWhitespace(nameof(LegalInfoUrl), openAIPluginManifest.LegalInfoUrl, nameof(OpenAIPluginManifest)); + } } diff --git a/src/lib/OpenAI/OpenApiPluginFactory.cs b/src/lib/OpenAI/OpenApiPluginFactory.cs index 9e66ff5..1a06a52 100644 --- a/src/lib/OpenAI/OpenApiPluginFactory.cs +++ b/src/lib/OpenAI/OpenApiPluginFactory.cs @@ -1,15 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. namespace Microsoft.OpenApi.ApiManifest.OpenAI; -public class OpenApiPluginFactory +public static class OpenApiPluginFactory { - public static OpenAIPluginManifest CreateOpenAIPluginManifest() + public static OpenAIPluginManifest CreateOpenAIPluginManifest(string nameForModel, string nameForHuman, string logoUrl, string contactEmail, string legalInfoUrl, string schemaVersion = "v1") { - var manifest = new OpenAIPluginManifest - { - SchemaVersion = "v1" - }; - return manifest; + return new OpenAIPluginManifest(nameForModel: nameForModel, nameForHuman: nameForHuman, logoUrl: logoUrl, contactEmail: contactEmail, legalInfoUrl: legalInfoUrl, schemaVersion: schemaVersion); } } \ No newline at end of file diff --git a/src/lib/Properties/AssemblyInfo.cs b/src/lib/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..75d6ba2 --- /dev/null +++ b/src/lib/Properties/AssemblyInfo.cs @@ -0,0 +1,5 @@ + + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.OpenApi.ApiManifest.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010079141b19153e38ff281aa3cfb29e887c0c27af8da48ff54bdb50364ea31cda51b165adc776f54768d5b18b1a15ee7167e8befaafd0d89eac38788820a0cfb3f5867fbc46c7faef5cf1d1f6000490f4a0781311170da4f51b5b16e4fa7c4f27964996548dfe565dc67b5829d8dc0229ef83aebfe8a3b4a67a24e6b836bc7d12d2")] diff --git a/src/lib/Publisher.cs b/src/lib/Publisher.cs index 8c192c1..5fa090f 100644 --- a/src/lib/Publisher.cs +++ b/src/lib/Publisher.cs @@ -1,5 +1,5 @@ +using Microsoft.OpenApi.ApiManifest.Helpers; using System.Text.Json; -using System.Text.RegularExpressions; namespace Microsoft.OpenApi.ApiManifest; @@ -11,8 +11,6 @@ public class Publisher private const string NameProperty = "name"; private const string ContactEmailProperty = "contactEmail"; - private static readonly Regex s_emailRegex = new(@"^[^@\s]+@[^@\s]+$", RegexOptions.Compiled, Constants.DefaultRegexTimeout); - public Publisher(string name, string contactEmail) { Validate(name, contactEmail); @@ -32,10 +30,8 @@ public void Write(Utf8JsonWriter writer) Validate(Name, ContactEmail); writer.WriteStartObject(); - writer.WriteString(NameProperty, Name); writer.WriteString(ContactEmailProperty, ContactEmail); - writer.WriteEndObject(); } @@ -47,14 +43,8 @@ internal static Publisher Load(JsonElement value) private static void Validate(string? name, string? contactEmail) { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentNullException(name, String.Format(ErrorMessage.FieldIsRequired, "name", "publisher")); - - if (string.IsNullOrWhiteSpace(contactEmail)) - throw new ArgumentNullException(contactEmail, String.Format(ErrorMessage.FieldIsRequired, "contactEmail", "publisher")); - - if (!s_emailRegex.IsMatch(contactEmail)) - throw new ArgumentException(string.Format(ErrorMessage.FieldIsNotValid, "contactEmail"), contactEmail); + ValidationHelpers.ValidateNullOrWhitespace(nameof(name), name, nameof(Publisher)); + ValidationHelpers.ValidateEmail(nameof(contactEmail), contactEmail, nameof(Publisher)); } private static readonly FixedFieldMap handlers = new() diff --git a/src/lib/RequestInfo.cs b/src/lib/RequestInfo.cs index 4274222..c06d106 100644 --- a/src/lib/RequestInfo.cs +++ b/src/lib/RequestInfo.cs @@ -1,3 +1,4 @@ +using Microsoft.OpenApi.ApiManifest.Helpers; using System.Text.Json; namespace Microsoft.OpenApi.ApiManifest; diff --git a/src/lib/apimanifest.csproj b/src/lib/apimanifest.csproj index af3b2b4..d86e684 100644 --- a/src/lib/apimanifest.csproj +++ b/src/lib/apimanifest.csproj @@ -19,7 +19,7 @@ API Manifest Microsoft.OpenApi.ApiManifest Microsoft.OpenApi.ApiManifest - sgKey.snk + ..\sgKey.snk \ No newline at end of file diff --git a/src/lib/sgKey.snk b/src/sgKey.snk similarity index 100% rename from src/lib/sgKey.snk rename to src/sgKey.snk diff --git a/src/tests/PluginTests.cs b/src/tests/PluginTests.cs deleted file mode 100644 index f8b272b..0000000 --- a/src/tests/PluginTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -// Write tests for OpenAIPluginManifest - -using Microsoft.OpenApi.ApiManifest.OpenAI; -using System.Text.Json; - -namespace Tests.OpenAI -{ - public class OpenAIPluginManifestTests - { - [Fact] - public void LoadOpenAIPluginManifest() - { - var json = @"{ - ""schema_version"": ""1.0.0"", - ""name_for_human"": ""OpenAI GPT-3"", - ""name_for_model"": ""openai-gpt3"", - ""description_for_human"": ""OpenAI GPT-3 is a language model that generates text based on prompts."" , - ""description_for_model"": ""OpenAI GPT-3 is a language model that generates text based on prompts."", - ""auth"": { - ""type"": ""none"" - }, - ""api"": { - ""type"": ""openapi"", - ""url"": ""https://api.openai.com/v1"" - }, - ""logo_url"": ""https://avatars.githubusercontent.com/foo"", - ""contact_email"": ""joe@demo.com"" - }"; - - var doc = JsonDocument.Parse(json); - var manifest = OpenAIPluginManifest.Load(doc.RootElement); - - Assert.Equal("1.0.0", manifest.SchemaVersion); - Assert.Equal("OpenAI GPT-3", manifest.NameForHuman); - Assert.Equal("openai-gpt3", manifest.NameForModel); - Assert.Equal("OpenAI GPT-3 is a language model that generates text based on prompts.", manifest.DescriptionForHuman); - Assert.Equal("OpenAI GPT-3 is a language model that generates text based on prompts.", manifest.DescriptionForModel); - Assert.Equal("none", manifest.Auth?.Type); - Assert.Equal("openapi", manifest.Api?.Type); - Assert.Equal("https://api.openai.com/v1", manifest.Api?.Url); - Assert.Equal("https://avatars.githubusercontent.com/foo", manifest.LogoUrl); - Assert.Equal("joe@demo.com", manifest.ContactEmail); - } - - // Create minimal OpenAIPluginManifest - [Fact] - public void WriteOpenAIPluginManifest() - { - var manifest = new OpenAIPluginManifest - { - SchemaVersion = "1.0.0", - NameForHuman = "OpenAI GPT-3", - NameForModel = "openai-gpt3", - DescriptionForHuman = "OpenAI GPT-3 is a language model that generates text based on prompts.", - DescriptionForModel = "OpenAI GPT-3 is a language model that generates text based on prompts.", - Auth = new ManifestNoAuth(), - Api = new Api - { - Type = "openapi", - Url = "https://api.openai.com/v1", - IsUserAuthenticated = false - }, - LogoUrl = "https://avatars.githubusercontent.com/bar", - ContactEmail = "joe@test.com" - }; - - // serialize using the Write method - var stream = new MemoryStream(); - var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); - manifest.Write(writer); - writer.Flush(); - stream.Position = 0; - var reader = new StreamReader(stream); - var json = reader.ReadToEnd(); - - Assert.Equal(@"{ - ""schema_version"": ""1.0.0"", - ""name_for_human"": ""OpenAI GPT-3"", - ""name_for_model"": ""openai-gpt3"", - ""description_for_human"": ""OpenAI GPT-3 is a language model that generates text based on prompts."", - ""description_for_model"": ""OpenAI GPT-3 is a language model that generates text based on prompts."", - ""auth"": { - ""type"": ""none"" - }, - ""api"": { - ""type"": ""openapi"", - ""url"": ""https://api.openai.com/v1"", - ""is_user_authenticated"": false - }, - ""logo_url"": ""https://avatars.githubusercontent.com/bar"", - ""contact_email"": ""joe@test.com"" -}", json, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true); - } - - [Fact] - public void WriteOAuthTest() - { - var manifest = new OpenAIPluginManifest - { - SchemaVersion = "1.0.0", - NameForHuman = "TestOAuth", - NameForModel = "TestOAuthModel", - DescriptionForHuman = "SomeHumanDescription", - DescriptionForModel = "SomeModelDescription", - Auth = new ManifestOAuthAuth - { - AuthorizationUrl = "https://api.openai.com/oauth/authorize", - }, - Api = new Api - { - Type = "openapi", - Url = "https://api.openai.com/v1", - IsUserAuthenticated = false - }, - LogoUrl = "https://avatars.githubusercontent.com/bar", - ContactEmail = "joe@test.com" - }; - - // serialize using the Write method - var stream = new MemoryStream(); - var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); - manifest.Write(writer); - writer.Flush(); - stream.Position = 0; - var reader = new StreamReader(stream); - var json = reader.ReadToEnd(); - - Assert.Equal(@"{ - ""schema_version"": ""1.0.0"", - ""name_for_human"": ""TestOAuth"", - ""name_for_model"": ""TestOAuthModel"", - ""description_for_human"": ""SomeHumanDescription"", - ""description_for_model"": ""SomeModelDescription"", - ""auth"": { - ""type"": ""oauth"", - ""authorization_url"": ""https://api.openai.com/oauth/authorize"" - }, - ""api"": { - ""type"": ""openapi"", - ""url"": ""https://api.openai.com/v1"", - ""is_user_authenticated"": false - }, - ""logo_url"": ""https://avatars.githubusercontent.com/bar"", - ""contact_email"": ""joe@test.com"" -}", json, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true); - } - - } - - -} \ No newline at end of file diff --git a/src/tests/Usings.cs b/src/tests/Usings.cs deleted file mode 100644 index f685d78..0000000 --- a/src/tests/Usings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using Microsoft.OpenApi.ApiManifest; -global using Xunit; diff --git a/src/tests/tests.csproj b/tests/ApiManifest.Tests/ApiManifest.Tests.csproj similarity index 60% rename from src/tests/tests.csproj rename to tests/ApiManifest.Tests/ApiManifest.Tests.csproj index 9bab270..63efb30 100644 --- a/src/tests/tests.csproj +++ b/tests/ApiManifest.Tests/ApiManifest.Tests.csproj @@ -1,15 +1,22 @@ - + net7.0 enable enable - + True false true + Microsoft.OpenApi.ApiManifest.Tests + Microsoft.OpenApi.ApiManifest.Tests + ..\..\src\sgKey.snk + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -23,7 +30,7 @@ - + diff --git a/src/tests/BasicTests.cs b/tests/ApiManifest.Tests/BasicTests.cs similarity index 77% rename from src/tests/BasicTests.cs rename to tests/ApiManifest.Tests/BasicTests.cs index faf5f5c..18cfb72 100644 --- a/src/tests/BasicTests.cs +++ b/tests/ApiManifest.Tests/BasicTests.cs @@ -1,9 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; -namespace Tests.ApiManifest; - +namespace Microsoft.OpenApi.ApiManifest.Tests; public class BasicTests { private readonly ApiManifestDocument exampleApiManifest; @@ -60,6 +62,10 @@ public void DeserializeDocument() var actualAuth = apiManifest.ApiDependencies["example"].AuthorizationRequirements; Assert.Equivalent(expectedAuth?.ClientIdentifier, actualAuth?.ClientIdentifier); Assert.Equivalent(expectedAuth?.Access?[0]?.Content?.ToJsonString(), actualAuth?.Access?[0]?.Content?.ToJsonString()); + Assert.NotNull(exampleApiManifest.Extensions["api-manifest-extension"]); + Assert.Equal(exampleApiManifest.Extensions["api-manifest-extension"]?.ToString(), apiManifest.Extensions?["api-manifest-extension"]?.ToString()); + Assert.NotNull(exampleApiManifest.ApiDependencies["example"]?.Extensions?["EXAMPLE-API-DEPENDENCY-EXTENSION"]); + Assert.Equal(exampleApiManifest.ApiDependencies["example"]?.Extensions?["example-API-dependency-extension"]?.ToString(), apiManifest.ApiDependencies["example"]?.Extensions?["example-api-dependency-extension"]?.ToString()); } @@ -175,29 +181,36 @@ private static ApiManifestDocument CreateDocument() Publisher = new("Microsoft", "example@example.org"), ApiDependencies = new() { { "example", new() + { + ApiDescriptionUrl = "https://example.org", + ApiDeploymentBaseUrl = "https://example.org/v1.0/", + AuthorizationRequirements = new() { - ApiDescriptionUrl = "https://example.org", - ApiDeploymentBaseUrl = "https://example.org/v1.0/", - AuthorizationRequirements = new() - { - ClientIdentifier = "1234", - Access = new() { - new () { Type= "application", Content = new JsonObject() { - { "scopes", new JsonArray() {"User.Read.All"} }} - } , - new () { Type= "delegated", Content = new JsonObject() { - { "scopes", new JsonArray() {"User.Read", "Mail.Read"} }} - } + ClientIdentifier = "1234", + Access = new() { + new() { Type = "application", Content = new JsonObject() { + { "scopes", new JsonArray() { "User.Read.All" } } } + }, + new() { Type = "delegated", Content = new JsonObject() { + { "scopes", new JsonArray() { "User.Read", "Mail.Read" } } } } - }, - Requests = new() { - new () { Method = "GET", UriTemplate = "/api/v1/endpoint" }, - new () { Method = "POST", UriTemplate = "/api/v1/endpoint"} } + }, + Requests = new() { + new() { Method = "GET", UriTemplate = "/api/v1/endpoint" }, + new() { Method = "POST", UriTemplate = "/api/v1/endpoint" } + }, + Extensions = new() + { + { "example-api-dependency-extension", "dependency-extension-value" } } } + } + }, + Extensions = new() + { + { "api-manifest-extension", "manifest-extension-value" } } }; } - -} \ No newline at end of file +} diff --git a/src/tests/CreateTests.cs b/tests/ApiManifest.Tests/CreateTests.cs similarity index 91% rename from src/tests/CreateTests.cs rename to tests/ApiManifest.Tests/CreateTests.cs index 8bf5f96..2db3749 100644 --- a/src/tests/CreateTests.cs +++ b/tests/ApiManifest.Tests/CreateTests.cs @@ -1,7 +1,9 @@ -using System.Text.Json.Nodes; +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. -namespace Tests.ApiManifest; +using System.Text.Json.Nodes; +namespace Microsoft.OpenApi.ApiManifest.Tests; public class CreateTests { @@ -52,8 +54,10 @@ public void CreateApiDependencyWithInvalidApiDeploymentBaseUrl(string apiDeploym { _ = Assert.Throws(() => { - var apiDependency = new ApiDependency(); - apiDependency.ApiDeploymentBaseUrl = apiDeploymentBaseUrl; + var apiDependency = new ApiDependency + { + ApiDeploymentBaseUrl = apiDeploymentBaseUrl + }; } ); } @@ -88,4 +92,4 @@ public void CreateApiManifestWithAuthorizationRequirements() Assert.Equal("oauth2", apiManifest?.ApiDependencies["Contoso.Api"]?.AuthorizationRequirements?.Access?[0].Type); } -} \ No newline at end of file +} diff --git a/tests/ApiManifest.Tests/GlobalUsings.cs b/tests/ApiManifest.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/tests/ApiManifest.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/ApiManifest.Tests/Helpers/ParsingHelpersTests.cs b/tests/ApiManifest.Tests/Helpers/ParsingHelpersTests.cs new file mode 100644 index 0000000..09e5e45 --- /dev/null +++ b/tests/ApiManifest.Tests/Helpers/ParsingHelpersTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.Helpers; +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.Tests.Helpers +{ + public class ParsingHelpersTests + { + private readonly string exampleKeyValuePair; + private readonly JsonDocument exampleJsonDoc; + + public ParsingHelpersTests() + { + exampleKeyValuePair = "foo=bar;foo1=bar1;foo2=bar3"; + var json = """ + { + "foo": ["a", "b", "c"], + "bar": { + "foo1": "bar1" + } + } + """; + exampleJsonDoc = JsonDocument.Parse(json); + } + + [Fact] + public void GetList() + { + var listOfString = ParsingHelpers.GetList(exampleJsonDoc.RootElement.GetProperty("foo"), + (v) => + { + var value = v.GetString(); + return string.IsNullOrWhiteSpace(value) ? string.Empty : value; + }); + Assert.Equal(3, listOfString.Count); + Assert.Equal(listOfString, new List { "a", "b", "c" }); + } + + [Fact] + public void GetMap() + { + var map = ParsingHelpers.GetMap(exampleJsonDoc.RootElement.GetProperty("bar"), + (v) => + { + var value = v.GetString(); + return string.IsNullOrWhiteSpace(value) ? string.Empty : value; + }); + Assert.Equal(1, map?.Count); + Assert.Equal("bar1", map?["foo1"]); + } + + [Fact] + public void GetOrderedMap() + { + var orderedMap = ParsingHelpers.GetOrderedMap(exampleJsonDoc.RootElement.GetProperty("bar"), + (v) => + { + var value = v.GetString(); + return string.IsNullOrWhiteSpace(value) ? string.Empty : value; + }); + Assert.Equal(1, orderedMap?.Count); + Assert.Equal("bar1", orderedMap?["foo1"]); + } + + [Fact] + public void GetMapOfString() + { + var mapOfString = ParsingHelpers.GetMapOfString(exampleJsonDoc.RootElement.GetProperty("bar")); + Assert.Equal(1, mapOfString?.Count); + Assert.Equal("bar1", mapOfString?["foo1"]); + } + + [Fact] + public void GetListOfString() + { + var listOfString = ParsingHelpers.GetListOfString(exampleJsonDoc.RootElement.GetProperty("foo")); + Assert.Equal(3, listOfString.Count); + Assert.Equal(listOfString, new List { "a", "b", "c" }); + } + + [Fact] + public void GetHashSetOfString() + { + var hashSetOfString = ParsingHelpers.GetHashSetOfString(exampleJsonDoc.RootElement.GetProperty("foo")); + Assert.Equal(3, hashSetOfString.Count); + Assert.Equal(hashSetOfString, new HashSet { "a", "b", "c" }); + } + + [Fact] + public void GetOrderedHashSetOfString() + { + var hashSetOfString = ParsingHelpers.GetOrderedHashSetOfString(exampleJsonDoc.RootElement.GetProperty("foo")); + Assert.Equal(3, hashSetOfString.Count); + Assert.Equal(hashSetOfString, new SortedSet { "a", "b", "c" }); + } + + [Fact] + public void ParseProperties() + { + var kvPairs = ParsingHelpers.ParseProperties(exampleKeyValuePair); + Assert.Equal(3, kvPairs.Count); + Assert.Equal("bar", kvPairs["foo"]); + Assert.Equal("bar1", kvPairs["foo1"]); + Assert.Equal("bar3", kvPairs["foo2"]); + } + + [Fact] + public void ParseKeyValuePair() + { + var kvPairs = ParsingHelpers.ParseKey(exampleKeyValuePair); + Assert.Equal(3, kvPairs.Count()); + } + } +} diff --git a/tests/ApiManifest.Tests/OpenAIPluginManifestTests.cs b/tests/ApiManifest.Tests/OpenAIPluginManifestTests.cs new file mode 100644 index 0000000..e446fd1 --- /dev/null +++ b/tests/ApiManifest.Tests/OpenAIPluginManifestTests.cs @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.OpenApi.ApiManifest.OpenAI; +using Microsoft.OpenApi.ApiManifest.OpenAI.Authentication; +using System.Text.Json; + +namespace Microsoft.OpenApi.ApiManifest.Tests; + +public class OpenAIPluginManifestTests +{ + // With no auth. + [Fact] + public void LoadOpenAIPluginManifestWithNoAuth() + { + var json = """ + { + "schema_version": "1.0.0", + "name_for_human": "OpenAI GPT-3", + "name_for_model": "openai-gpt3", + "description_for_human": "OpenAI GPT-3 is a language model that generates text based on prompts." , + "description_for_model": "OpenAI GPT-3 is a language model that generates text based on prompts.", + "auth": { + "type": "none" + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1" + }, + "logo_url": "https://avatars.githubusercontent.com/foo", + "legal_info_url": "https://legalinfo.foobar.com", + "contact_email": "joe@demo.com" + } + """; + + var doc = JsonDocument.Parse(json); + var manifest = OpenAIPluginManifest.Load(doc.RootElement); + + Assert.Equal("1.0.0", manifest.SchemaVersion); + Assert.Equal("OpenAI GPT-3", manifest.NameForHuman); + Assert.Equal("openai-gpt3", manifest.NameForModel); + Assert.Equal("OpenAI GPT-3 is a language model that generates text based on prompts.", manifest.DescriptionForHuman); + Assert.Equal("OpenAI GPT-3 is a language model that generates text based on prompts.", manifest.DescriptionForModel); + Assert.Equal("none", manifest.Auth?.Type); + Assert.Equal("openapi", manifest.Api?.Type); + Assert.Equal("https://api.openai.com/v1", manifest.Api?.Url); + Assert.Equal("https://avatars.githubusercontent.com/foo", manifest.LogoUrl); + Assert.Equal("joe@demo.com", manifest.ContactEmail); + Assert.Equal("https://legalinfo.foobar.com", manifest.LegalInfoUrl); + } + + [Fact] + public void WriteOpenAIPluginManifestWithNoAuth() + { + var manifest = CreateManifestPlugIn(); + + // serialize using the Write method + var stream = new MemoryStream(); + var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + manifest.Write(writer); + writer.Flush(); + stream.Position = 0; + var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + + Assert.Equal(""" + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "none" + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "contact_email": "joe@test.com", + "legal_info_url": "https://legalinfo.foobar.com" + } + """, json, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true); + } + + // With no OAuth. + [Fact] + public void LoadOpenAIPluginManifestWithOAuth() + { + var json = """ + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "oauth", + "authorization_url": "https://api.openai.com/oauth/authorize", + "authorization_content_type": "application/json", + "client_url": "https://api.openai.com/oauth/token", + "scope": "all:all", + "verification_tokens": { + "openai": "dummy_verification_token" + } + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "legal_info_url": "https://legalinfo.foobar.com", + "contact_email": "joe@test.com" + } + """; + + var doc = JsonDocument.Parse(json); + var manifest = OpenAIPluginManifest.Load(doc.RootElement); + + Assert.Equal("1.0.0", manifest.SchemaVersion); + Assert.Equal("TestOAuth", manifest.NameForHuman); + Assert.Equal("TestOAuthModel", manifest.NameForModel); + Assert.Equal("SomeHumanDescription", manifest.DescriptionForHuman); + Assert.Equal("SomeModelDescription", manifest.DescriptionForModel); + Assert.Equal("oauth", manifest.Auth?.Type); + Assert.Equal("https://api.openai.com/oauth/authorize", ((ManifestOAuthAuth?)manifest.Auth)?.AuthorizationUrl); + Assert.Equal("application/json", ((ManifestOAuthAuth?)manifest.Auth)?.AuthorizationContentType); + Assert.Equal("https://api.openai.com/oauth/token", ((ManifestOAuthAuth?)manifest.Auth)?.ClientUrl); + Assert.Equal("all:all", ((ManifestOAuthAuth?)manifest.Auth)?.Scope); + Assert.Equal("dummy_verification_token", ((ManifestOAuthAuth?)manifest.Auth)?.VerificationTokens["OPENAI"]); + Assert.Equal("openapi", manifest.Api?.Type); + Assert.Equal("https://api.openai.com/v1", manifest.Api?.Url); + Assert.Equal("https://avatars.githubusercontent.com/bar", manifest.LogoUrl); + Assert.Equal("https://legalinfo.foobar.com", manifest.LegalInfoUrl); + Assert.Equal("joe@test.com", manifest.ContactEmail); + } + + [Fact] + public void WriteOpenAIPluginManifestWithOAuth() + { + var manifest = CreateManifestPlugIn(); + manifest.Auth = new ManifestOAuthAuth + { + AuthorizationUrl = "https://api.openai.com/oauth/authorize", + AuthorizationContentType = "application/json", + ClientUrl = "https://api.openai.com/oauth/token", + Scope = "all:all", + VerificationTokens = new VerificationTokens + { + { "openai", "dummy_verification_token" } + } + }; + + // serialize using the Write method + var stream = new MemoryStream(); + var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + manifest.Write(writer); + writer.Flush(); + stream.Position = 0; + var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + + Assert.Equal(""" + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "oauth", + "client_url": "https://api.openai.com/oauth/token", + "scope": "all:all", + "authorization_url": "https://api.openai.com/oauth/authorize", + "authorization_content_type": "application/json", + "verification_tokens": { + "openai": "dummy_verification_token" + } + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "contact_email": "joe@test.com", + "legal_info_url": "https://legalinfo.foobar.com" + } + """, json, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true); + } + + // With user HTTP. + [Fact] + public void LoadOpenAIPluginManifestWithUserHttp() + { + var json = """ + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "user_http", + "authorization_type": "bearer" + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "legal_info_url": "https://legalinfo.foobar.com", + "contact_email": "joe@test.com" + } + """; + + var doc = JsonDocument.Parse(json); + var manifest = OpenAIPluginManifest.Load(doc.RootElement); + + Assert.Equal("1.0.0", manifest.SchemaVersion); + Assert.Equal("TestOAuth", manifest.NameForHuman); + Assert.Equal("TestOAuthModel", manifest.NameForModel); + Assert.Equal("SomeHumanDescription", manifest.DescriptionForHuman); + Assert.Equal("SomeModelDescription", manifest.DescriptionForModel); + Assert.Equal("user_http", manifest.Auth?.Type); + Assert.Equal("bearer", ((ManifestUserHttpAuth?)manifest.Auth)?.AuthorizationType); + Assert.Equal("openapi", manifest.Api?.Type); + Assert.Equal("https://api.openai.com/v1", manifest.Api?.Url); + Assert.Equal("https://avatars.githubusercontent.com/bar", manifest.LogoUrl); + Assert.Equal("joe@test.com", manifest.ContactEmail); + Assert.Equal("https://legalinfo.foobar.com", manifest.LegalInfoUrl); + } + + [Fact] + public void WriteOpenAIPluginManifestWithUserHttp() + { + var manifest = CreateManifestPlugIn(); + manifest.Auth = new ManifestUserHttpAuth("bearer"); + + // serialize using the Write method + var stream = new MemoryStream(); + var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + manifest.Write(writer); + writer.Flush(); + stream.Position = 0; + var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + + Assert.Equal(""" + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "user_http", + "authorization_type": "bearer" + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "contact_email": "joe@test.com", + "legal_info_url": "https://legalinfo.foobar.com" + } + """, json, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true); + } + + // With service HTTP. + [Fact] + public void LoadOpenAIPluginManifestWithServiceHttp() + { + var json = """ + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "service_http", + "authorization_type": "bearer", + "verification_tokens": { + "openai": "dummy_verification_token" + } + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "contact_email": "joe@test.com", + "legal_info_url": "https://legalinfo.foobar.com" + } + """; + + var doc = JsonDocument.Parse(json); + var manifest = OpenAIPluginManifest.Load(doc.RootElement); + + Assert.Equal("1.0.0", manifest.SchemaVersion); + Assert.Equal("TestOAuth", manifest.NameForHuman); + Assert.Equal("TestOAuthModel", manifest.NameForModel); + Assert.Equal("SomeHumanDescription", manifest.DescriptionForHuman); + Assert.Equal("SomeModelDescription", manifest.DescriptionForModel); + Assert.Equal("service_http", manifest.Auth?.Type); + Assert.Equal("bearer", ((ManifestServiceHttpAuth?)manifest.Auth)?.AuthorizationType); + Assert.Equal("dummy_verification_token", ((ManifestServiceHttpAuth?)manifest.Auth)?.VerificationTokens["OPENAI"]); + Assert.Equal("openapi", manifest.Api?.Type); + Assert.Equal("https://api.openai.com/v1", manifest.Api?.Url); + Assert.Equal("https://avatars.githubusercontent.com/bar", manifest.LogoUrl); + Assert.Equal("joe@test.com", manifest.ContactEmail); + Assert.Equal("https://legalinfo.foobar.com", manifest.LegalInfoUrl); + } + + [Fact] + public void WriteOpenAIPluginManifestWithServiceHttp() + { + var manifest = CreateManifestPlugIn(); + manifest.Auth = new ManifestServiceHttpAuth(new VerificationTokens + { + { "openai", "dummy_verification_token" } + }); + + // serialize using the Write method + var stream = new MemoryStream(); + var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + manifest.Write(writer); + writer.Flush(); + stream.Position = 0; + var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + + Assert.Equal(""" + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "service_http", + "authorization_type": "bearer", + "verification_tokens": { + "openai": "dummy_verification_token" + } + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "contact_email": "joe@test.com", + "legal_info_url": "https://legalinfo.foobar.com" + } + """, json, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true); + } + + [Theory] + [InlineData("foo")] + [InlineData("foo@")] + [InlineData("foo@@bar.com")] + [InlineData("foo @bar.com")] + public void WriteOpenAIPluginManifestWithInvalidContactEmail(string email) + { + var manifest = CreateManifestPlugIn(); + manifest.ContactEmail = email; + + // serialize using the Write method + var stream = new MemoryStream(); + var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + _ = Assert.Throws(() => + { + manifest.Write(writer); + }); + } + + [Fact] + public void LoadOpenAIPluginManifestWithInvalidAuthType() + { + var json = """ + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "NOT_VALID_service_http", + "authorization_type": "bearer", + "verification_tokens": { + "openai": "dummy_verification_token" + } + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "contact_email": "joe@test.com", + "legal_info_url": "https://legalinfo.foobar.com" + } + """; + + var doc = JsonDocument.Parse(json); + var exception = Assert.Throws(() => + { + _ = OpenAIPluginManifest.Load(doc.RootElement); + }); + Assert.Equal("Unknown auth type: not_valid_service_http (Parameter 'value')", exception.Message); + } + + [Fact] + public void LoadOpenAIPluginManifestWithIncompleteManifest() + { + var json = """ + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "service_http", + "authorization_type": "bearer", + "verification_tokens": { + "openai": "dummy_verification_token" + } + }, + "api": { + "type": "openapi", + "url": "https://api.openai.com/v1", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "contact_email": "joe@test.com", + "legal_info_url": "https://legalinfo.foobar.com" + } + """; + + var doc = JsonDocument.Parse(json); + var exception = Assert.Throws(() => + { + _ = OpenAIPluginManifest.Load(doc.RootElement); + }); + Assert.Equal("'NameForModel' is a required property of 'OpenAIPluginManifest'. (Parameter 'NameForModel')", exception.Message); + } + + [Fact] + public void LoadOpenAIPluginManifestWithNoApiUrl() + { + var json = """ + { + "schema_version": "1.0.0", + "name_for_human": "TestOAuth", + "name_for_model": "TestOAuthModel", + "description_for_human": "SomeHumanDescription", + "description_for_model": "SomeModelDescription", + "auth": { + "type": "service_http", + "authorization_type": "bearer", + "verification_tokens": { + "openai": "dummy_verification_token" + } + }, + "api": { + "type": "openapi", + "is_user_authenticated": false + }, + "logo_url": "https://avatars.githubusercontent.com/bar", + "contact_email": "joe@test.com", + "legal_info_url": "https://legalinfo.foobar.com" + } + """; + + var doc = JsonDocument.Parse(json); + var exception = Assert.Throws(() => + { + _ = OpenAIPluginManifest.Load(doc.RootElement); + }); + Assert.Equal("'Url' is a required property of 'Api'. (Parameter 'Url')", exception.Message); + } + + private static OpenAIPluginManifest CreateManifestPlugIn() + { + var manifest = OpenApiPluginFactory.CreateOpenAIPluginManifest( + schemaVersion: "1.0.0", + nameForHuman: "TestOAuth", + nameForModel: "TestOAuthModel", + logoUrl: "https://avatars.githubusercontent.com/bar", + legalInfoUrl: "https://legalinfo.foobar.com", + contactEmail: "joe@test.com"); + manifest.DescriptionForHuman = "SomeHumanDescription"; + manifest.DescriptionForModel = "SomeModelDescription"; + manifest.Auth = new ManifestNoAuth(); + manifest.Api = new Api("openapi", "https://api.openai.com/v1") + { + IsUserAuthenticated = false + }; + return manifest; + } +}