diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..81d9de8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,166 @@ +# To learn more about .editorconfig see https://aka.ms/editorconfigdocs + +# All files +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +; Force VS to recommend underscore at the start of created private fields. +[*.cs] +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = suggestion + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ + +# name all constant fields using UpperCase +dotnet_naming_rule.constant_fields_should_be_upper_case.severity = warning +dotnet_naming_rule.constant_fields_should_be_upper_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_upper_case.style = upper_case_style + +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.upper_case_style.capitalization = pascal_case + +; Avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# IDE0024: Use block body for operators +csharp_style_expression_bodied_operators = when_on_single_line + +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = none + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = silent + +# CA1003: Use generic event handler instances +dotnet_diagnostic.CA1003.severity = suggestion + +# CA1056: URI-like properties should not be strings +dotnet_diagnostic.CA1056.severity = suggestion + +# CA1819: Properties should not return arrays +dotnet_diagnostic.CA1819.severity = suggestion + +# CA1724: Type names should not match namespaces +dotnet_diagnostic.CA1724.severity = suggestion + +# CA1030: Use events where appropriate +dotnet_diagnostic.CA1030.severity = silent + +# CA1002: Do not expose generic lists +dotnet_diagnostic.CA1002.severity = suggestion + +# CA2227: Collection properties should be read only +dotnet_diagnostic.CA2227.severity = suggestion + +# CA1014: Mark assemblies with CLSCompliant +dotnet_diagnostic.CA1014.severity = none + +# CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = suggestion + +# CA1028: Enum Storage should be Int32 +dotnet_diagnostic.CA1028.severity = none + +# CA1826: Don't apply to ordefault methods +dotnet_code_quality.CA1826.exclude_ordefault_methods = true + +# CA1308: Normalize strings to uppercase +dotnet_diagnostic.CA1308.severity = none + +# CA1032: Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = none + +# C0162 exclude extension methods from needing null contract assertions +dotnet_code_quality.CA1062.exclude_extension_method_this_parameter = true + +# CA2225: Operator overloads have named alternates +dotnet_diagnostic.CA2225.severity = none + +# CA2229: Implement serialization constructors +dotnet_diagnostic.CA2229.severity = none + +# CA1508: Avoid dead conditional code - Too many false positives for this to be useful. +dotnet_diagnostic.CA1508.severity = none + +# SA1309: Field names should not begin with underscore +dotnet_diagnostic.SA1309.severity = none + +# SA1101: Prefix local calls with this +dotnet_diagnostic.SA1101.severity = none + +# SA1600: Elements should be documented +dotnet_diagnostic.SA1600.severity = none + +# SA1623: Property summary documentation should match accessors +dotnet_diagnostic.SA1623.severity = none + +# SA1200: Using directives should be placed correctly +dotnet_diagnostic.SA1200.severity = none + +# SA1633: File should have header +dotnet_diagnostic.SA1633.severity = none + +# SA1116: Split parameters should start on line after declaration +dotnet_diagnostic.SA1116.severity = none + +# CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.CA1303.severity = none + +# SA1204: Static elements should appear before instance elements +dotnet_diagnostic.SA1204.severity = none + +# CA1721: Property names should not match get methods +dotnet_diagnostic.CA1721.severity = suggestion + +# CA1720: Identifier contains type name +dotnet_diagnostic.CA1720.severity = none + +# SA1602: Enumeration items should be documented +dotnet_diagnostic.SA1602.severity = suggestion + +# SA1642: Constructor summary documentation should begin with standard text +dotnet_diagnostic.SA1642.severity = none + +# SA1611: Element parameters should be documented +dotnet_diagnostic.SA1611.severity = none + +# SA1615: Element return value should be documented +dotnet_diagnostic.SA1615.severity = none + +# SA1201: Elements should appear in the correct order +dotnet_diagnostic.SA1201.severity = none + +# CA1054: URI-like parameters should not be strings +dotnet_diagnostic.CA1054.severity = none + +# SA1515: Single-line comment should be preceded by blank line +dotnet_diagnostic.SA1515.severity = none + +# CA1716: Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = none + +# CA1812: Internal types should be instantiated (suppressing because analyser doesn't understand serialisers) +dotnet_diagnostic.CA1812.severity = suggestion + +# CA1055: URI-like return values should not be strings +dotnet_diagnostic.CA1055.severity = none + +# CA1062: Exclude our guard methods from null-check warnings. +dotnet_code_quality.CA1062.null_check_validation_methods = NotNull + +dotnet_diagnostic.SA0001.severity=silent + +# CA2234: Pass system uri objects instead of strings +dotnet_diagnostic.CA2234.severity = silent +csharp_style_namespace_declarations=file_scoped:warning diff --git a/.github/workflows/sdk-api-build.yml b/.github/workflows/sdk-api-build.yml new file mode 100644 index 0000000..0af8a75 --- /dev/null +++ b/.github/workflows/sdk-api-build.yml @@ -0,0 +1,56 @@ +name: SDK API Build + +on: + push: + branches: [ develop, main ] + pull_request: + branches: [ develop ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v0.9.7 + with: + versionSpec: '5.x' + + - name: Determine Version + id: gitversion + uses: gittools/actions/gitversion/execute@v0.9.7 + + - name: Setup .NET 6 (SDK) + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 6.0.x + source-url: https://nuget.pkg.github.com/enclave-networks/index.json + env: + NUGET_AUTH_TOKEN: ${{github.token}} + + - name: Build + run: dotnet build Enclave.Sdk.Api.sln -c Release /p:Version=${{ steps.gitversion.outputs.SemVer }} + + - name: Unit Tests + working-directory: tests/Enclave.Sdk.Api.Tests + run: dotnet test -c Release + + - name: Push Github Source Packages + if: github.event_name == 'push' + run: dotnet nuget push src/**/*${{ steps.gitversion.outputs.SemVer }}.nupkg --api-key ${{github.token}} -s https://nuget.pkg.github.com/enclave-networks/index.json --skip-duplicate --no-symbols true + + - name: Create Release + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ steps.gitversion.outputs.SemVer }} + release_name: Release v${{ steps.gitversion.outputs.SemVer }} + body: Latest SDK API Release \ No newline at end of file diff --git a/.gitignore b/.gitignore index dfcfd56..4e3ee73 100644 --- a/.gitignore +++ b/.gitignore @@ -348,3 +348,4 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ +tests/Enclave.Sdk.Api.Tests/IntegrationTests.cs diff --git a/Enclave.Sdk.Api.sln b/Enclave.Sdk.Api.sln new file mode 100644 index 0000000..d5378fa --- /dev/null +++ b/Enclave.Sdk.Api.sln @@ -0,0 +1,49 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Enclave.Sdk.Api", "src\Enclave.Sdk\Enclave.Sdk.Api.csproj", "{AE9A2DA2-DBA1-4A0F-AE39-26E777C2CDF4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Enclave.Sdk.Api.Tests", "tests\Enclave.Sdk.Api.Tests\Enclave.Sdk.Api.Tests.csproj", "{AC896965-74E9-405F-BA4E-E9D7336B906B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{CA499537-7F6B-4E85-934A-04368AF78E9E}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{822BFA5E-EC93-45BF-B36E-1CAD1E7D88B1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{BD898880-875C-43EA-B22C-A7C801CFA607}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + GitVersion.yml = GitVersion.yml + .github\workflows\sdk-api-build.yml = .github\workflows\sdk-api-build.yml + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AE9A2DA2-DBA1-4A0F-AE39-26E777C2CDF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE9A2DA2-DBA1-4A0F-AE39-26E777C2CDF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE9A2DA2-DBA1-4A0F-AE39-26E777C2CDF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE9A2DA2-DBA1-4A0F-AE39-26E777C2CDF4}.Release|Any CPU.Build.0 = Release|Any CPU + {AC896965-74E9-405F-BA4E-E9D7336B906B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC896965-74E9-405F-BA4E-E9D7336B906B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC896965-74E9-405F-BA4E-E9D7336B906B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC896965-74E9-405F-BA4E-E9D7336B906B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {AE9A2DA2-DBA1-4A0F-AE39-26E777C2CDF4} = {CA499537-7F6B-4E85-934A-04368AF78E9E} + {AC896965-74E9-405F-BA4E-E9D7336B906B} = {822BFA5E-EC93-45BF-B36E-1CAD1E7D88B1} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {33E7CCF5-8133-4138-BDDC-6B2785C7DDC6} + EndGlobalSection +EndGlobal diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..fc2356f --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,11 @@ +assembly-versioning-scheme: None +mode: ContinuousDelivery +next-version: 0.0.1 +branches: + main: + mode: ContinuousDelivery + develop: + increment: Patch +ignore: + sha: [] +merge-message-formats: {} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..ad6badf --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,17 @@ + + + + 9.0 + https://github.com/enclave-networks/Enclave.Sdk.Api + enable + AllEnabledByDefault + + + + + + + All + + + \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/AuthorityClient.cs b/src/Enclave.Sdk/Clients/AuthorityClient.cs new file mode 100644 index 0000000..c413012 --- /dev/null +++ b/src/Enclave.Sdk/Clients/AuthorityClient.cs @@ -0,0 +1,14 @@ +using Enclave.Sdk.Api.Clients.Interfaces; + +namespace Enclave.Sdk.Api.Clients; + +public class AuthorityClient : ClientBase, IAuthorityClient +{ + private string _orgRoute; + + public AuthorityClient(HttpClient httpClient, string orgRoute) + : base(httpClient) + { + _orgRoute = orgRoute; + } +} diff --git a/src/Enclave.Sdk/Clients/ClientBase.cs b/src/Enclave.Sdk/Clients/ClientBase.cs new file mode 100644 index 0000000..3afbd75 --- /dev/null +++ b/src/Enclave.Sdk/Clients/ClientBase.cs @@ -0,0 +1,85 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Json; +using System.Net.Mime; +using System.Text; +using System.Text.Json; + +namespace Enclave.Sdk.Api.Clients; + +/// +/// Base class used for commonly accessed methods and properties for all clients. +/// +public abstract class ClientBase +{ + /// + /// HttpClient used for all clients API calls. + /// + protected HttpClient HttpClient { get; } + + /// + /// Constructor to setup all required fields this is called by all child classes. + /// + /// HttpClient with baseUrl of the API used for all calls. + protected ClientBase(HttpClient httpClient) + { + HttpClient = httpClient; + } + + /// + /// Get a string content for use with HttpClient. + /// + /// the object type to encode. + /// the object to encode. + /// String content of object. + /// throws if data provided is null. + protected StringContent CreateJsonContent(TModel data) + { + if (data is null) + { + throw new ArgumentNullException(nameof(data), "Data should not be null"); + } + + var json = JsonSerializer.Serialize(data, Constants.JsonSerializerOptions); + var stringContent = new StringContent(json, Encoding.UTF8, MediaTypeNames.Application.Json); + + return stringContent; + } + + /// + /// Desreialize the httpContent. + /// + /// the object type to deserialise to. + /// httpContent from the API call. + /// the object of type specified. + protected async Task DeserialiseAsync(HttpContent httpContent) + { + if (httpContent is null) + { + return default; + } + + return await httpContent.ReadFromJsonAsync(Constants.JsonSerializerOptions); + } + + /// + /// Checks model is not null. + /// + /// Type being checked. + /// object being checked. + protected static void EnsureNotNull([NotNull] TModel? model) + { + if (model is null) + { + Throw(); + } + } + + /// + /// Throws an error every time it's called. + /// + /// Throws every time this is called. + [DoesNotReturn] + private static void Throw() => + throw new InvalidOperationException("Return from API is null please ensure you've entered the correct data or raise an issue"); +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/DNSClient.cs b/src/Enclave.Sdk/Clients/DNSClient.cs new file mode 100644 index 0000000..73c8e42 --- /dev/null +++ b/src/Enclave.Sdk/Clients/DNSClient.cs @@ -0,0 +1,14 @@ +using Enclave.Sdk.Api.Clients.Interfaces; + +namespace Enclave.Sdk.Api.Clients; + +public class DnsClient : ClientBase, IDnsClient +{ + private string _orgRoute; + + public DnsClient(HttpClient httpClient, string orgRoute) + : base(httpClient) + { + _orgRoute = orgRoute; + } +} diff --git a/src/Enclave.Sdk/Clients/EnrolmentKeysClient.cs b/src/Enclave.Sdk/Clients/EnrolmentKeysClient.cs new file mode 100644 index 0000000..bc98e38 --- /dev/null +++ b/src/Enclave.Sdk/Clients/EnrolmentKeysClient.cs @@ -0,0 +1,14 @@ +using Enclave.Sdk.Api.Clients.Interfaces; + +namespace Enclave.Sdk.Api.Clients; + +public class EnrolmentKeysClient : ClientBase, IEnrolmentKeysClient +{ + private string _orgRoute; + + public EnrolmentKeysClient(HttpClient httpClient, string orgRoute) + : base(httpClient) + { + _orgRoute = orgRoute; + } +} diff --git a/src/Enclave.Sdk/Clients/Interfaces/IAuthorityClient.cs b/src/Enclave.Sdk/Clients/Interfaces/IAuthorityClient.cs new file mode 100644 index 0000000..49b89d6 --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/IAuthorityClient.cs @@ -0,0 +1,5 @@ +namespace Enclave.Sdk.Api.Clients.Interfaces; + +public interface IAuthorityClient +{ +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/Interfaces/IDnsClient.cs b/src/Enclave.Sdk/Clients/Interfaces/IDnsClient.cs new file mode 100644 index 0000000..c409dd2 --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/IDnsClient.cs @@ -0,0 +1,5 @@ +namespace Enclave.Sdk.Api.Clients.Interfaces; + +public interface IDnsClient +{ +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/Interfaces/IEnrolmentKeysClient.cs b/src/Enclave.Sdk/Clients/Interfaces/IEnrolmentKeysClient.cs new file mode 100644 index 0000000..a11d9cd --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/IEnrolmentKeysClient.cs @@ -0,0 +1,5 @@ +namespace Enclave.Sdk.Api.Clients.Interfaces; + +public interface IEnrolmentKeysClient +{ +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/Interfaces/ILogsClient.cs b/src/Enclave.Sdk/Clients/Interfaces/ILogsClient.cs new file mode 100644 index 0000000..11595fd --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/ILogsClient.cs @@ -0,0 +1,5 @@ +namespace Enclave.Sdk.Api.Clients.Interfaces; + +public interface ILogsClient +{ +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/Interfaces/IOrganisationClient.cs b/src/Enclave.Sdk/Clients/Interfaces/IOrganisationClient.cs new file mode 100644 index 0000000..519151d --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/IOrganisationClient.cs @@ -0,0 +1,103 @@ +using Enclave.Sdk.Api.Data; +using Enclave.Sdk.Api.Data.Account; +using Enclave.Sdk.Api.Data.Organisations; +using Enclave.Sdk.Api.Data.PatchModel; + +namespace Enclave.Sdk.Api.Clients.Interfaces; + +/// +/// Provides access to organisation level API calls and organisation related clients. +/// For more information please refer to our API docs at https://api.enclave.io/. +/// +public interface IOrganisationClient +{ + /// + /// The organisation selected and the one used to create this client. + /// + AccountOrganisation Organisation { get; } + + /// + /// An instance of associated with the current organisaiton. + /// + IAuthorityClient Authority { get; } + + /// + /// An instance of associated with the current organisaiton. + /// + IDnsClient Dns { get; } + + /// + /// An instance of associated with the current organisaiton. + /// + IEnrolmentKeysClient EnrolmentKeys { get; } + + /// + /// An instance of associated with the current organisaiton. + /// + ILogsClient Logs { get; } + + /// + /// An instance of associated with the current organisaiton. + /// + IPoliciesClient Policies { get; } + + /// + /// An instance of associated with the current organisaiton. + /// + ISystemsClient Systems { get; } + + /// + /// An instance of associated with the current organisaiton. + /// + ITagsClient Tags { get; } + + /// + /// An instance of associated with the current organisaiton. + /// + IUnapprovedSystemsClient UnapprovedSystems { get; } + + /// + /// Get more detail on your current organisaiton. + /// + /// A detailed organisation model. + Task GetAsync(); + + /// + /// Gets the users that have access to the current organisaiton. + /// + /// List of users associated with the current organisation. + Task> GetOrganisationUsersAsync(); + + /// + /// Get a list of invites that haven't been accepted. + /// + /// ReadOnlyList of pending invites. + Task> GetPendingInvitesAsync(); + + /// + /// Invite a user provided they haven't already been invited. + /// + /// Email address of the user you want to invite. + Task InviteUserAsync(string emailAddress); + + /// + /// Cancel and invite before it's accepted. + /// + /// Email address of the user who's invite you want to revoke. + Task CancelInviteAync(string emailAddress); + + /// + /// Removes a user from the organisation. + /// + /// The id of the users you want to remove. + Task RemoveUserAsync(string accountId); + + /// + /// Patch request to update the organisation. + /// Use Builder.cs to help you generate the dictionary. + /// + /// An instance of used to setup our patch request. + /// The updated organisation. + /// Throws if builder is null. + Task UpdateAsync(PatchBuilder builder); +} diff --git a/src/Enclave.Sdk/Clients/Interfaces/IPoliciesClient.cs b/src/Enclave.Sdk/Clients/Interfaces/IPoliciesClient.cs new file mode 100644 index 0000000..b3283e4 --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/IPoliciesClient.cs @@ -0,0 +1,5 @@ +namespace Enclave.Sdk.Api.Clients.Interfaces; + +public interface IPoliciesClient +{ +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/Interfaces/ISystemsClient.cs b/src/Enclave.Sdk/Clients/Interfaces/ISystemsClient.cs new file mode 100644 index 0000000..b0cf022 --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/ISystemsClient.cs @@ -0,0 +1,5 @@ +namespace Enclave.Sdk.Api.Clients.Interfaces; + +public interface ISystemsClient +{ +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/Interfaces/ITagsClient.cs b/src/Enclave.Sdk/Clients/Interfaces/ITagsClient.cs new file mode 100644 index 0000000..39647d1 --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/ITagsClient.cs @@ -0,0 +1,20 @@ +using Enclave.Sdk.Api.Data.Pagination; +using Enclave.Sdk.Api.Data.Tags; + +namespace Enclave.Sdk.Api.Clients.Interfaces; + +/// +/// Access and search for tags. +/// +public interface ITagsClient +{ + /// + /// Gets a paginated list of tags which can be searched and interated upon. + /// + /// A partial matching search term. + /// Sort order for the pagination. + /// Which page number do you want to return. + /// How many tags per page. + /// A paginated response model with links to get the previous, next, first and last pages. + Task> GetAsync(string? searchTerm = null, TagQuerySortOrder? sortOrder = null, int? pageNumber = null, int? perPage = null); +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/Interfaces/IUnapprovedSystemsClient.cs b/src/Enclave.Sdk/Clients/Interfaces/IUnapprovedSystemsClient.cs new file mode 100644 index 0000000..aa34fdc --- /dev/null +++ b/src/Enclave.Sdk/Clients/Interfaces/IUnapprovedSystemsClient.cs @@ -0,0 +1,5 @@ +namespace Enclave.Sdk.Api.Clients.Interfaces; + +public interface IUnapprovedSystemsClient +{ +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/LogsClient.cs b/src/Enclave.Sdk/Clients/LogsClient.cs new file mode 100644 index 0000000..53e7df1 --- /dev/null +++ b/src/Enclave.Sdk/Clients/LogsClient.cs @@ -0,0 +1,14 @@ +using Enclave.Sdk.Api.Clients.Interfaces; + +namespace Enclave.Sdk.Api.Clients; + +public class LogsClient : ClientBase, ILogsClient +{ + private string _orgRoute; + + public LogsClient(HttpClient httpClient, string orgRoute) + : base(httpClient) + { + _orgRoute = orgRoute; + } +} diff --git a/src/Enclave.Sdk/Clients/OrganisationClient.cs b/src/Enclave.Sdk/Clients/OrganisationClient.cs new file mode 100644 index 0000000..d876310 --- /dev/null +++ b/src/Enclave.Sdk/Clients/OrganisationClient.cs @@ -0,0 +1,136 @@ +using System.Net.Http.Json; +using Enclave.Sdk.Api.Clients.Interfaces; +using Enclave.Sdk.Api.Data; +using Enclave.Sdk.Api.Data.Account; +using Enclave.Sdk.Api.Data.Organisations; +using Enclave.Sdk.Api.Data.PatchModel; + +namespace Enclave.Sdk.Api.Clients; + +/// +public class OrganisationClient : ClientBase, IOrganisationClient +{ + private string _orgRoute; + + /// + /// This constructor is called by when setting up the . + /// It also calls the constructor. + /// + /// an instance of httpClient with a baseURL referencing the API. + /// The current organisaiton used for routing the API calls. + public OrganisationClient(HttpClient httpClient, AccountOrganisation currentOrganisation) + : base(httpClient) + { + Organisation = currentOrganisation; + _orgRoute = $"org/{Organisation.OrgId}"; + } + + /// + public AccountOrganisation Organisation { get; } + + /// + public IAuthorityClient Authority => throw new NotImplementedException(); + + /// + public IDnsClient Dns => throw new NotImplementedException(); + + /// + public IEnrolmentKeysClient EnrolmentKeys => throw new NotImplementedException(); + + /// + public ILogsClient Logs => throw new NotImplementedException(); + + /// + public IPoliciesClient Policies => throw new NotImplementedException(); + + /// + public ISystemsClient Systems => throw new NotImplementedException(); + + /// + public ITagsClient Tags => throw new NotImplementedException(); + + /// + public IUnapprovedSystemsClient UnapprovedSystems => throw new NotImplementedException(); + + /// + public async Task GetAsync() + { + var model = await HttpClient.GetFromJsonAsync(_orgRoute); + + EnsureNotNull(model); + + return model; + } + + /// + public async Task UpdateAsync(PatchBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + using var encoded = CreateJsonContent(builder.Send()); + var result = await HttpClient.PatchAsync(_orgRoute, encoded); + + var model = await DeserialiseAsync(result.Content); + + EnsureNotNull(model); + + return model; + } + + /// + public async Task> GetOrganisationUsersAsync() + { + var model = await HttpClient.GetFromJsonAsync($"{_orgRoute}/users"); + + EnsureNotNull(model); + + return model.Users ?? Array.Empty(); + } + + /// + public async Task RemoveUserAsync(string accountId) + { + await HttpClient.DeleteAsync($"{_orgRoute}/users/{accountId}"); + } + + /// + public async Task> GetPendingInvitesAsync() + { + var model = await HttpClient.GetFromJsonAsync($"{_orgRoute}/invites"); + + EnsureNotNull(model); + + return model.Invites ?? Array.Empty(); + } + + /// + public async Task InviteUserAsync(string emailAddress) + { + using var encoded = CreateJsonContent(new OrganisationInvite + { + EmailAddress = emailAddress, + }); + + var result = await HttpClient.PostAsync($"{_orgRoute}/invites", encoded); + } + + /// + public async Task CancelInviteAync(string emailAddress) + { + using var encoded = CreateJsonContent(new OrganisationInvite + { + EmailAddress = emailAddress, + }); + + using var request = new HttpRequestMessage + { + Content = encoded, + Method = HttpMethod.Delete, + RequestUri = new Uri($"{HttpClient.BaseAddress}{_orgRoute}/invites"), + }; + + await HttpClient.SendAsync(request); + } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Clients/PoliciesClient.cs b/src/Enclave.Sdk/Clients/PoliciesClient.cs new file mode 100644 index 0000000..0f6aafd --- /dev/null +++ b/src/Enclave.Sdk/Clients/PoliciesClient.cs @@ -0,0 +1,14 @@ +using Enclave.Sdk.Api.Clients.Interfaces; + +namespace Enclave.Sdk.Api.Clients; + +public class PoliciesClient : ClientBase, IPoliciesClient +{ + private string _orgRoute; + + public PoliciesClient(HttpClient httpClient, string orgRoute) + : base(httpClient) + { + _orgRoute = orgRoute; + } +} diff --git a/src/Enclave.Sdk/Clients/SystemsClient.cs b/src/Enclave.Sdk/Clients/SystemsClient.cs new file mode 100644 index 0000000..fb4bc02 --- /dev/null +++ b/src/Enclave.Sdk/Clients/SystemsClient.cs @@ -0,0 +1,14 @@ +using Enclave.Sdk.Api.Clients.Interfaces; + +namespace Enclave.Sdk.Api.Clients; + +public class SystemsClient : ClientBase, ISystemsClient +{ + private string _orgRoute; + + public SystemsClient(HttpClient httpClient, string orgRoute) + : base(httpClient) + { + _orgRoute = orgRoute; + } +} diff --git a/src/Enclave.Sdk/Clients/TagsClient.cs b/src/Enclave.Sdk/Clients/TagsClient.cs new file mode 100644 index 0000000..43eea63 --- /dev/null +++ b/src/Enclave.Sdk/Clients/TagsClient.cs @@ -0,0 +1,62 @@ +using Enclave.Sdk.Api.Clients.Interfaces; +using Enclave.Sdk.Api.Data.Pagination; +using Enclave.Sdk.Api.Data.Tags; +using System.Net.Http.Json; +using System.Web; + +namespace Enclave.Sdk.Api.Clients; + +/// +public class TagsClient : ClientBase, ITagsClient +{ + private string _orgRoute; + + /// + /// Consutructor which will be called by when it's created. + /// + /// an instance of httpClient with a baseURL referencing the API. + /// the orgRoute which specifies the orgId. + public TagsClient(HttpClient httpClient, string orgRoute) + : base(httpClient) + { + _orgRoute = orgRoute; + } + + /// + public async Task> GetAsync(string? searchTerm = null, TagQuerySortOrder? sortOrder = null, int? pageNumber = null, int? perPage = null) + { + var queryString = BuildQueryString(searchTerm, sortOrder, pageNumber, perPage); + + var model = await HttpClient.GetFromJsonAsync>($"{_orgRoute}/tags?{queryString}"); + + EnsureNotNull(model); + + return model; + } + + private static string? BuildQueryString(string? searchTerm, TagQuerySortOrder? sortOrder, int? pageNumber, int? perPage) + { + var queryString = HttpUtility.ParseQueryString(string.Empty); + if (searchTerm is not null) + { + queryString.Add("search", searchTerm); + } + + if (sortOrder is not null) + { + queryString.Add("sort", sortOrder.ToString()); + } + + if (pageNumber is not null) + { + queryString.Add("page", pageNumber.ToString()); + } + + if (perPage is not null) + { + queryString.Add("per_page", perPage.ToString()); + } + + return queryString.ToString(); + } +} diff --git a/src/Enclave.Sdk/Clients/UnapprovedSystemsClient.cs b/src/Enclave.Sdk/Clients/UnapprovedSystemsClient.cs new file mode 100644 index 0000000..5eebb5f --- /dev/null +++ b/src/Enclave.Sdk/Clients/UnapprovedSystemsClient.cs @@ -0,0 +1,14 @@ +using Enclave.Sdk.Api.Clients.Interfaces; + +namespace Enclave.Sdk.Api.Clients; + +public class UnapprovedSystemsClient : ClientBase, IUnapprovedSystemsClient +{ + private string _orgRoute; + + public UnapprovedSystemsClient(HttpClient httpClient, string orgRoute) + : base(httpClient) + { + _orgRoute = orgRoute; + } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Constants.cs b/src/Enclave.Sdk/Constants.cs new file mode 100644 index 0000000..b516ecd --- /dev/null +++ b/src/Enclave.Sdk/Constants.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Enclave.Sdk.Api; + +internal static class Constants +{ + public static JsonSerializerOptions JsonSerializerOptions { get; } = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public const string ApiUrl = "https://api.enclave.io"; +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Account/AccountId.cs b/src/Enclave.Sdk/Data/Account/AccountId.cs new file mode 100644 index 0000000..8cf6769 --- /dev/null +++ b/src/Enclave.Sdk/Data/Account/AccountId.cs @@ -0,0 +1,11 @@ +using TypedIds; + +namespace Enclave.Sdk.Api.Data.Account; + +/// +/// AccountId struct to distinguish between Id types. +/// +[TypedId] +public readonly partial struct AccountId +{ +} diff --git a/src/Enclave.Sdk/Data/Account/AccountOrganisation.cs b/src/Enclave.Sdk/Data/Account/AccountOrganisation.cs new file mode 100644 index 0000000..a0dd9bc --- /dev/null +++ b/src/Enclave.Sdk/Data/Account/AccountOrganisation.cs @@ -0,0 +1,33 @@ +namespace Enclave.Sdk.Api.Data.Account; + +/// +/// Contains the role an account has within an organisation. +/// +public class AccountOrganisation +{ + /// + /// The organisation ID. + /// + public OrganisationId OrgId { get; init; } + + /// + /// The organisation name. + /// + public string OrgName { get; init; } = default!; + + /// + /// The user's role within the organisation. + /// + public UserOrganisationRole Role { get; init; } +} + +/// +/// Account orgs response model. +/// +public class AccountOrganisationTopLevel +{ + /// + /// The set of organisations. + /// + public List Orgs { get; init; } = default!; +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Account/OrganisationId.cs b/src/Enclave.Sdk/Data/Account/OrganisationId.cs new file mode 100644 index 0000000..5907c18 --- /dev/null +++ b/src/Enclave.Sdk/Data/Account/OrganisationId.cs @@ -0,0 +1,11 @@ +using TypedIds; + +namespace Enclave.Sdk.Api.Data.Account; + +/// +/// OrganisaitonId struct to distinguish between Id types. +/// +[TypedId] +public readonly partial struct OrganisationId +{ +} diff --git a/src/Enclave.Sdk/Data/Account/UserOrganisationRole.cs b/src/Enclave.Sdk/Data/Account/UserOrganisationRole.cs new file mode 100644 index 0000000..d5af46d --- /dev/null +++ b/src/Enclave.Sdk/Data/Account/UserOrganisationRole.cs @@ -0,0 +1,7 @@ +namespace Enclave.Sdk.Api.Data.Account; + +public enum UserOrganisationRole +{ + Owner, + Admin, +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Organisations/Enum/BillingEventLevel.cs b/src/Enclave.Sdk/Data/Organisations/Enum/BillingEventLevel.cs new file mode 100644 index 0000000..7b92bd8 --- /dev/null +++ b/src/Enclave.Sdk/Data/Organisations/Enum/BillingEventLevel.cs @@ -0,0 +1,22 @@ +namespace Enclave.Sdk.Api.Data.Organisations.Enum; + +/// +/// Defines levels for billing events. +/// +public enum BillingEventLevel +{ + /// + /// Info. + /// + Information, + + /// + /// Warning. + /// + Warning, + + /// + /// Error. + /// + Error, +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Organisations/Enum/OrganisationPlan.cs b/src/Enclave.Sdk/Data/Organisations/Enum/OrganisationPlan.cs new file mode 100644 index 0000000..8ed8025 --- /dev/null +++ b/src/Enclave.Sdk/Data/Organisations/Enum/OrganisationPlan.cs @@ -0,0 +1,8 @@ +namespace Enclave.Sdk.Api.Data.Organisations.Enum; + +public enum OrganisationPlan +{ + Starter = 0, + Pro = 1, + Business = 2, +} diff --git a/src/Enclave.Sdk/Data/Organisations/Organisation.cs b/src/Enclave.Sdk/Data/Organisations/Organisation.cs new file mode 100644 index 0000000..e329646 --- /dev/null +++ b/src/Enclave.Sdk/Data/Organisations/Organisation.cs @@ -0,0 +1,55 @@ +using Enclave.Sdk.Api.Data.Account; +using Enclave.Sdk.Api.Data.Organisations.Enum; + +namespace Enclave.Sdk.Api.Data.Organisations; + +/// +/// Organisation properties model. +/// +public class Organisation +{ + /// + /// The organisation ID. + /// + public OrganisationId Id { get; init; } + + /// + /// The UTC timestamp at which the organisation was created. + /// + public DateTime Created { get; init; } + + /// + /// The name of the organisation. + /// + public string Name { get; init; } = default!; + + /// + /// The current plan the organisation is on. + /// + public OrganisationPlan Plan { get; init; } + + /// + /// The provided website info for the org (if known). + /// + public string? Website { get; init; } + + /// + /// The provided phone info for the org (if known). + /// + public string? Phone { get; init; } + + /// + /// The maximum number of systems this organisation can have enrolled. + /// + public int MaxSystems { get; init; } + + /// + /// The number of enrolled systems. + /// + public long EnrolledSystems { get; init; } + + /// + /// The number of systems waiting to be approved. + /// + public long UnapprovedSystems { get; init; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Organisations/OrganisationBillingEvent.cs b/src/Enclave.Sdk/Data/Organisations/OrganisationBillingEvent.cs new file mode 100644 index 0000000..e54d139 --- /dev/null +++ b/src/Enclave.Sdk/Data/Organisations/OrganisationBillingEvent.cs @@ -0,0 +1,24 @@ +using Enclave.Sdk.Api.Data.Organisations.Enum; + +namespace Enclave.Sdk.Api.Data.Organisations; + +/// +/// Defines the properties available for a billing event on an organisation. +/// +public class OrganisationBillingEvent +{ + /// + /// The code indicating the billing event. + /// + public string Code { get; init; } = default!; + + /// + /// A human-readable message describing the event. + /// + public string Message { get; init; } = default!; + + /// + /// The event 'level'. + /// + public BillingEventLevel Level { get; init; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Organisations/OrganisationInvite.cs b/src/Enclave.Sdk/Data/Organisations/OrganisationInvite.cs new file mode 100644 index 0000000..aa61746 --- /dev/null +++ b/src/Enclave.Sdk/Data/Organisations/OrganisationInvite.cs @@ -0,0 +1,12 @@ +namespace Enclave.Sdk.Api.Data.Organisations; + +/// +/// Invite model. +/// +public class OrganisationInvite +{ + /// + /// The email address of the user to invite. + /// + public string EmailAddress { get; init; } = default!; +} diff --git a/src/Enclave.Sdk/Data/Organisations/OrganisationPendingInvites.cs b/src/Enclave.Sdk/Data/Organisations/OrganisationPendingInvites.cs new file mode 100644 index 0000000..a11b1eb --- /dev/null +++ b/src/Enclave.Sdk/Data/Organisations/OrganisationPendingInvites.cs @@ -0,0 +1,12 @@ +namespace Enclave.Sdk.Api.Data.Organisations; + +/// +/// Model for the pending list of org invites. +/// +public class OrganisationPendingInvites +{ + /// + /// The set of outstanding invites that have been sent for this organisation. + /// + public IReadOnlyList? Invites { get; init; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Organisations/OrganisationUser.cs b/src/Enclave.Sdk/Data/Organisations/OrganisationUser.cs new file mode 100644 index 0000000..59b7519 --- /dev/null +++ b/src/Enclave.Sdk/Data/Organisations/OrganisationUser.cs @@ -0,0 +1,44 @@ +using Enclave.Sdk.Api.Data.Account; + +namespace Enclave.Sdk.Api.Data.Organisations; + +/// +/// Defines the properties of a user's membership of an organisation. +/// +public class OrganisationUser +{ + /// + /// The account ID. + /// + public AccountId Id { get; init; } + + /// + /// The user email address. + /// + public string? EmailAddress { get; init; } + + /// + /// The user first name. + /// + public string? FirstName { get; init; } + + /// + /// The user last name. + /// + public string? LastName { get; init; } + + /// + /// The UTC timestamp for when the user joined the organisation. + /// + public DateTime JoinDate { get; init; } + + /// + /// The user's role in the organisation. + /// + public UserOrganisationRole Role { get; init; } +} + +public class OrganisationUsersTopLevel +{ + public IReadOnlyList? Users { get; init; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Pagination/PaginatedResponseModel.cs b/src/Enclave.Sdk/Data/Pagination/PaginatedResponseModel.cs new file mode 100644 index 0000000..6018bb9 --- /dev/null +++ b/src/Enclave.Sdk/Data/Pagination/PaginatedResponseModel.cs @@ -0,0 +1,23 @@ +namespace Enclave.Sdk.Api.Data.Pagination; + +/// +/// Response model for paginated data. +/// +/// The item type. +public class PaginatedResponseModel +{ + /// + /// Metadata for the paginated data. + /// + public PaginationMetadata Metadata { get; init; } + + /// + /// The related links for the current page of data. + /// + public PaginationLinks Links { get; init; } + + /// + /// The requested page of items. + /// + public IEnumerable Items { get; init; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Pagination/PaginationLinks.cs b/src/Enclave.Sdk/Data/Pagination/PaginationLinks.cs new file mode 100644 index 0000000..d72c346 --- /dev/null +++ b/src/Enclave.Sdk/Data/Pagination/PaginationLinks.cs @@ -0,0 +1,27 @@ +namespace Enclave.Sdk.Api.Data.Pagination; + +/// +/// Defines the available pagination links. +/// +public class PaginationLinks +{ + /// + /// The first page of data. + /// + public Uri First { get; init; } + + /// + /// The previous page of data (or null if this is the first page). + /// + public Uri? Prev { get; init; } + + /// + /// The next page of data (or null if this is the last page). + /// + public Uri? Next { get; init; } + + /// + /// The last page of data. + /// + public Uri Last { get; init; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Pagination/PaginationMetadata.cs b/src/Enclave.Sdk/Data/Pagination/PaginationMetadata.cs new file mode 100644 index 0000000..e66fee8 --- /dev/null +++ b/src/Enclave.Sdk/Data/Pagination/PaginationMetadata.cs @@ -0,0 +1,32 @@ +namespace Enclave.Sdk.Api.Data.Pagination; + +/// +/// Defines the metadata attached to a paginated response. +/// +public class PaginationMetadata +{ + /// + /// The total number of items (across all pages). + /// + public int Total { get; init; } + + /// + /// The first page number. + /// + public int FirstPage { get; init; } + + /// + /// The previous page number (null if this is the first page). + /// + public int? PrevPage { get; init; } + + /// + /// The last page number. + /// + public int LastPage { get; init; } + + /// + /// The next page number (null if this is the last page). + /// + public int? NextPage { get; init; } +} diff --git a/src/Enclave.Sdk/Data/PatchBuilder.cs b/src/Enclave.Sdk/Data/PatchBuilder.cs new file mode 100644 index 0000000..531a67e --- /dev/null +++ b/src/Enclave.Sdk/Data/PatchBuilder.cs @@ -0,0 +1,66 @@ +using Enclave.Sdk.Api.Data.PatchModel; +using System.Linq.Expressions; + +namespace Enclave.Sdk.Api.Data; + +/// +/// Class used to construct patch models. +/// +/// The Type we're updating. +public class PatchBuilder + where TModel : IPatchModel +{ + private Dictionary _patchDictionary = new Dictionary(); + + /// + /// Set a value in the global dictionary to the updated value. + /// + /// The type of the value you're updating. + /// Expression tree witht he property you want to update. + /// The new value. + /// Builder for fluent building. + /// Throws if either propExpr or newValue are null. + /// If the selected propExpr body is null. + public PatchBuilder Set(Expression> propExpr, TValue newValue) + { + if (newValue is null) + { + throw new ArgumentNullException(nameof(newValue), "please specificy a valid new value."); + } + + if (propExpr is null) + { + throw new ArgumentNullException(nameof(propExpr), "please specificy a valid property expression."); + } + + // Get the property expression. + var property = propExpr.Body as MemberExpression; + + if (property is null) + { + throw new ArgumentException("Specified property expression is not valid."); + } + + var propName = property.Member.Name; + + _patchDictionary.Add(propName, newValue); + + return this; + } + + /// + /// Build the Dictionary and return it. then reset the dictionary so the builder can be reused. + /// + /// Dictionary built using the Set model in this class. + internal Dictionary Send() + { + try + { + return _patchDictionary; + } + finally + { + _patchDictionary = new Dictionary(); + } + } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/PatchModel/AccountPatch.cs b/src/Enclave.Sdk/Data/PatchModel/AccountPatch.cs new file mode 100644 index 0000000..c77b186 --- /dev/null +++ b/src/Enclave.Sdk/Data/PatchModel/AccountPatch.cs @@ -0,0 +1,22 @@ +namespace Enclave.Sdk.Api.Data.PatchModel; + +/// +/// Defines the modifiable properties of an account. +/// +public class AccountPatch : IPatchModel +{ + /// + /// The user's declared first name. + /// + public string? FirstName { get; set; } + + /// + /// The user's declared last name. + /// + public string? LastName { get; set; } + + /// + /// Whether the user has indicated they would like to recieve marketing emails. + /// + public bool? EmailNotificationsEnabled { get; set; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/PatchModel/DnsRecordPatch.cs b/src/Enclave.Sdk/Data/PatchModel/DnsRecordPatch.cs new file mode 100644 index 0000000..8412310 --- /dev/null +++ b/src/Enclave.Sdk/Data/PatchModel/DnsRecordPatch.cs @@ -0,0 +1,27 @@ +namespace Enclave.Sdk.Api.Data.PatchModel; + +/// +/// The patch model for a DNS record. Any values not provided will not be updated. +/// +public class DnsRecordPatch : IPatchModel +{ + /// + /// The name of the record (without the zone). + /// + public string Name { get; set; } = default!; + + /// + /// The set of tags to which this DNS name is applied. + /// + public IReadOnlyList? Tags { get; set; } + + /// + /// The set of systems to which this DNS name is applied. + /// + public IReadOnlyList? Systems { get; set; } + + /// + /// Any notes or additional info for this record. + /// + public string? Notes { get; set; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/PatchModel/DnsZonePatch.cs b/src/Enclave.Sdk/Data/PatchModel/DnsZonePatch.cs new file mode 100644 index 0000000..32b9aeb --- /dev/null +++ b/src/Enclave.Sdk/Data/PatchModel/DnsZonePatch.cs @@ -0,0 +1,17 @@ +namespace Enclave.Sdk.Api.Data.PatchModel; + +/// +/// Patch model for updating a zone. +/// +public class DnsZonePatch : IPatchModel +{ + /// + /// The name of the zone; changing the name of a zone will change the fully-qualified name of every DNS record. + /// + public string? Name { get; set; } + + /// + /// Update the notes for the zone. + /// + public string? Notes { get; set; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/PatchModel/IPatchModel.cs b/src/Enclave.Sdk/Data/PatchModel/IPatchModel.cs new file mode 100644 index 0000000..96e0059 --- /dev/null +++ b/src/Enclave.Sdk/Data/PatchModel/IPatchModel.cs @@ -0,0 +1,5 @@ +namespace Enclave.Sdk.Api.Data.PatchModel; + +public interface IPatchModel +{ +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/PatchModel/OrganisationPatch.cs b/src/Enclave.Sdk/Data/PatchModel/OrganisationPatch.cs new file mode 100644 index 0000000..ac3d8e5 --- /dev/null +++ b/src/Enclave.Sdk/Data/PatchModel/OrganisationPatch.cs @@ -0,0 +1,22 @@ +namespace Enclave.Sdk.Api.Data.PatchModel; + +/// +/// Model defining the modifiable properties for an organisation. +/// +public class OrganisationPatch : IPatchModel +{ + /// + /// The org name. + /// + public string? Name { get; set; } + + /// + /// The org website. + /// + public string? Website { get; set; } + + /// + /// A contact number for the organisation. + /// + public string? Phone { get; set; } +} diff --git a/src/Enclave.Sdk/Data/PatchModel/PolicyPatch.cs b/src/Enclave.Sdk/Data/PatchModel/PolicyPatch.cs new file mode 100644 index 0000000..4cb526a --- /dev/null +++ b/src/Enclave.Sdk/Data/PatchModel/PolicyPatch.cs @@ -0,0 +1,39 @@ +using Enclave.Sdk.Api.Data.Policy; + +namespace Enclave.Sdk.Api.Data.PatchModel; + +/// +/// Defines the modifiable properties of a policy. +/// +public class PolicyPatch : IPatchModel +{ + /// + /// The policy name/description. + /// + public string? Description { get; set; } + + /// + /// Whether or not the policy is enabled. + /// + public bool? IsEnabled { get; set; } + + /// + /// A set of sender tags. + /// + public string[]? SenderTags { get; set; } + + /// + /// A set of receiver tags. + /// + public string[]? ReceiverTags { get; set; } + + /// + /// The set of ACLs for the policy. + /// + public PolicyAclModel[]? Acls { get; set; } + + /// + /// Notes for the policy. + /// + public string? Notes { get; set; } +} diff --git a/src/Enclave.Sdk/Data/PatchModel/SystemPatch.cs b/src/Enclave.Sdk/Data/PatchModel/SystemPatch.cs new file mode 100644 index 0000000..e309b1a --- /dev/null +++ b/src/Enclave.Sdk/Data/PatchModel/SystemPatch.cs @@ -0,0 +1,27 @@ +namespace Enclave.Sdk.Api.Data.PatchModel; + +/// +/// Defines the modifiable properties of a system. +/// +public class SystemPatch : IPatchModel +{ + /// + /// The system description. + /// + public string? Description { get; set; } + + /// + /// Whether or not the system is enabled (and available for use). + /// + public bool? IsEnabled { get; set; } + + /// + /// The set of tags applied to the system. + /// + public string[]? Tags { get; set; } + + /// + /// Any notes or additional info for this system. + /// + public string? Notes { get; set; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Policy/PolicyAclModel.cs b/src/Enclave.Sdk/Data/Policy/PolicyAclModel.cs new file mode 100644 index 0000000..95ec7e5 --- /dev/null +++ b/src/Enclave.Sdk/Data/Policy/PolicyAclModel.cs @@ -0,0 +1,22 @@ +namespace Enclave.Sdk.Api.Data.Policy; + +/// +/// Model representing a single ACL entry for a policy. +/// +public class PolicyAclModel +{ + /// + /// The protocol. + /// + public PolicyAclProtocol Protocol { get; set; } + + /// + /// The port range (or single port) for the ACL. + /// + public string? Ports { get; set; } + + /// + /// An optional description. + /// + public string? Description { get; set; } +} diff --git a/src/Enclave.Sdk/Data/Policy/PolicyAclProtocol.cs b/src/Enclave.Sdk/Data/Policy/PolicyAclProtocol.cs new file mode 100644 index 0000000..aeba45d --- /dev/null +++ b/src/Enclave.Sdk/Data/Policy/PolicyAclProtocol.cs @@ -0,0 +1,9 @@ +namespace Enclave.Sdk.Api.Data.Policy; + +public enum PolicyAclProtocol +{ + Any, + Tcp, + Udp, + Icmp +} diff --git a/src/Enclave.Sdk/Data/Tags/TagItem.cs b/src/Enclave.Sdk/Data/Tags/TagItem.cs new file mode 100644 index 0000000..a9962d6 --- /dev/null +++ b/src/Enclave.Sdk/Data/Tags/TagItem.cs @@ -0,0 +1,37 @@ +namespace Enclave.Sdk.Api.Data.Tags; + +/// +/// Represents a single tag. +/// +public class TagItem +{ + /// + /// The tag name. + /// + public string Tag { get; init; } = default!; + + /// + /// The last time this tag was last referenced. + /// + public DateTime LastReferenced { get; init; } + + /// + /// The number of systems that reference this tag. + /// + public int Systems { get; init; } + + /// + /// The number of enrolment keys that reference this tag. + /// + public int Keys { get; init; } + + /// + /// The number of policies that reference this tag. + /// + public int Policies { get; init; } + + /// + /// The number of DNS records that reference this tag. + /// + public int DnsRecords { get; init; } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Data/Tags/TagQuerySortOrder.cs b/src/Enclave.Sdk/Data/Tags/TagQuerySortOrder.cs new file mode 100644 index 0000000..d44057f --- /dev/null +++ b/src/Enclave.Sdk/Data/Tags/TagQuerySortOrder.cs @@ -0,0 +1,8 @@ +namespace Enclave.Sdk.Api.Data.Tags; + +public enum TagQuerySortOrder +{ + Alphabetical, + RecentlyUsed, + ReferencedSystems, +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Enclave.Sdk.Api.csproj b/src/Enclave.Sdk/Enclave.Sdk.Api.csproj new file mode 100644 index 0000000..3e9595c --- /dev/null +++ b/src/Enclave.Sdk/Enclave.Sdk.Api.csproj @@ -0,0 +1,24 @@ + + + + 10.0 + net6.0 + Library + enable + enable + Enclave Networks Limited + True + True + false + README.md + + Provides a NuGet package that makes it easier to consume the Enclave Management APIs. + + + + + + + + + diff --git a/src/Enclave.Sdk/EnclaveClient.cs b/src/Enclave.Sdk/EnclaveClient.cs new file mode 100644 index 0000000..f4972b0 --- /dev/null +++ b/src/Enclave.Sdk/EnclaveClient.cs @@ -0,0 +1,124 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Reflection; +using System.Text.Json; +using Enclave.Sdk.Api.Clients; +using Enclave.Sdk.Api.Clients.Interfaces; +using Enclave.Sdk.Api.Data; +using Enclave.Sdk.Api.Data.Account; +using Enclave.Sdk.Api.Handlers; + +namespace Enclave.Sdk.Api; + +/// +/// Our main entry point for all API work. +/// +public class EnclaveClient +{ + private readonly HttpClient _httpClient; + + /// + /// Create an using settings found in the .enclave/credentials.json file in your user directory. + /// + /// Throws if options in file are null. + public EnclaveClient() + { + var options = GetSettingsFile(); + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + _httpClient = SetupHttpClient(options); + } + + /// + /// Simple Setup using just a PersonalAccessToken. + /// + /// The token created on the Enclave Portal. + public EnclaveClient(string personalAccessToken) + { + var options = new EnclaveClientOptions { PersonalAccessToken = personalAccessToken }; + + _httpClient = SetupHttpClient(options); + } + + /// + /// Setup all requirements for making API calls. + /// + /// Options for setting up the . + /// Throws if options are null. + public EnclaveClient(EnclaveClientOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + _httpClient = SetupHttpClient(options); + } + + /// + /// Gets a list of associated to the authorised user. + /// + /// List of organisation containing the OrgId and Name and the users role in that organisation. + /// throws when the Api returns a null response. + public async Task> GetOrganisationsAsync() + { + var organisations = await _httpClient.GetFromJsonAsync("/account/orgs", Constants.JsonSerializerOptions); + + if (organisations is null) + { + throw new InvalidOperationException("Could not deserialize orgs associated to this token"); + } + + return organisations.Orgs; + } + + /// + /// Create an from an . + /// + /// the from . + /// OrganisationClient that can be used for all following Api Calls related to an organisation. + public IOrganisationClient CreateOrganisationClient(AccountOrganisation organisation) + { + return new OrganisationClient(_httpClient, organisation); + } + + private EnclaveClientOptions? GetSettingsFile() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + var location = Path.Combine(userProfile, ".enclave", "credentials.json"); + + try + { + var json = File.ReadAllText(location); + var settings = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return settings; + } + catch (IOException) + { + return null; + } + } + + private static HttpClient SetupHttpClient(EnclaveClientOptions options) + { + var httpClient = new HttpClient(new ProblemDetailsHttpMessageHandler()) + { + BaseAddress = new Uri(options.BaseUrl), + }; + + if (!string.IsNullOrWhiteSpace(options.PersonalAccessToken)) + { + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.PersonalAccessToken); + } + + var clientHeader = new ProductInfoHeaderValue("Enclave.Sdk.Api", Assembly.GetExecutingAssembly().GetName().Version?.ToString()); + httpClient.DefaultRequestHeaders.UserAgent.Add(clientHeader); + return httpClient; + } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/EnclaveClientOptions.cs b/src/Enclave.Sdk/EnclaveClientOptions.cs new file mode 100644 index 0000000..54c7d55 --- /dev/null +++ b/src/Enclave.Sdk/EnclaveClientOptions.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Enclave.Sdk.Api; + +/// +/// A representation of options used when creating . +/// +public class EnclaveClientOptions +{ + /// + /// Default constructor which sets the BaseURL to the production version of the Enclave API. + /// + public EnclaveClientOptions() + { + BaseUrl = Constants.ApiUrl; + } + + /// + /// the Personal access token from the Enclave portal. + /// + public string? PersonalAccessToken { get; set; } + + /// + /// The base URL of the Enclave API endpoint. + /// + public string BaseUrl { get; set; } +} diff --git a/src/Enclave.Sdk/Exceptions/ProblemDetails.cs b/src/Enclave.Sdk/Exceptions/ProblemDetails.cs new file mode 100644 index 0000000..26f044a --- /dev/null +++ b/src/Enclave.Sdk/Exceptions/ProblemDetails.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Enclave.Sdk.Api.Exceptions; + +/// +/// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807. +/// +[JsonConverter(typeof(ProblemDetailsJsonConverter))] +public class ProblemDetails +{ + /// + /// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when + /// dereferenced, it provide human-readable documentation for the problem type + /// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be + /// "about:blank". + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// A short, human-readable summary of the problem type.It SHOULD NOT change from occurrence to occurrence + /// of the problem, except for purposes of localization(e.g., using proactive content negotiation; + /// see[RFC7231], Section 3.4). + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. + /// + [JsonPropertyName("status")] + public int? Status { get; set; } + + /// + /// A human-readable explanation specific to this occurrence of the problem. + /// + [JsonPropertyName("detail")] + public string? Detail { get; set; } + + /// + /// A URI reference that identifies the specific occurrence of the problem.It may or may not yield further information if dereferenced. + /// + [JsonPropertyName("instance")] + public string? Instance { get; set; } + + /// + /// Gets the for extension members. + /// + /// Problem type definitions MAY extend the problem details object with additional members. Extension members appear in the same namespace as + /// other members of a problem type. + /// + /// + /// + /// The round-tripping behavior for is determined by the implementation of the Input \ Output formatters. + /// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters. + /// + [JsonExtensionData] + public IDictionary Extensions { get; } = new Dictionary(StringComparer.Ordinal); +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Exceptions/ProblemDetailsException.cs b/src/Enclave.Sdk/Exceptions/ProblemDetailsException.cs new file mode 100644 index 0000000..af74bfc --- /dev/null +++ b/src/Enclave.Sdk/Exceptions/ProblemDetailsException.cs @@ -0,0 +1,15 @@ +namespace Enclave.Sdk.Api.Exceptions; + +internal class ProblemDetailsException : Exception +{ + public ProblemDetails ProblemDetails { get; } + + public HttpResponseMessage Response { get; } + + public ProblemDetailsException(ProblemDetails problemDetails, HttpResponseMessage response) + : base(problemDetails.Title) + { + ProblemDetails = problemDetails; + Response = response; + } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Exceptions/ProblemDetailsJsonConverter.cs b/src/Enclave.Sdk/Exceptions/ProblemDetailsJsonConverter.cs new file mode 100644 index 0000000..380c8ae --- /dev/null +++ b/src/Enclave.Sdk/Exceptions/ProblemDetailsJsonConverter.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Enclave.Sdk.Api.Exceptions; + +internal sealed class ProblemDetailsJsonConverter : JsonConverter +{ + private static readonly JsonEncodedText Type = JsonEncodedText.Encode("type"); + private static readonly JsonEncodedText Title = JsonEncodedText.Encode("title"); + private static readonly JsonEncodedText Status = JsonEncodedText.Encode("status"); + private static readonly JsonEncodedText Detail = JsonEncodedText.Encode("detail"); + private static readonly JsonEncodedText Instance = JsonEncodedText.Encode("instance"); + + public override ProblemDetails Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var problemDetails = new ProblemDetails(); + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Unexcepted end when reading JSON."); + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + ReadValue(ref reader, problemDetails, options); + } + + if (reader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException("Unexcepted end when reading JSON."); + } + + return problemDetails; + } + + public override void Write(Utf8JsonWriter writer, ProblemDetails value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + WriteProblemDetails(writer, value, options); + writer.WriteEndObject(); + } + + internal static void ReadValue(ref Utf8JsonReader reader, ProblemDetails value, JsonSerializerOptions options) + { + if (TryReadStringProperty(ref reader, Type, out var propertyValue)) + { + value.Type = propertyValue; + } + else if (TryReadStringProperty(ref reader, Title, out propertyValue)) + { + value.Title = propertyValue; + } + else if (TryReadStringProperty(ref reader, Detail, out propertyValue)) + { + value.Detail = propertyValue; + } + else if (TryReadStringProperty(ref reader, Instance, out propertyValue)) + { + value.Instance = propertyValue; + } + else if (reader.ValueTextEquals(Status.EncodedUtf8Bytes)) + { + reader.Read(); + if (reader.TokenType == JsonTokenType.Null) + { + // Nothing to do here. + } + else + { + value.Status = reader.GetInt32(); + } + } + else + { + var key = reader.GetString()!; + reader.Read(); + value.Extensions[key] = JsonSerializer.Deserialize(ref reader, typeof(object), options); + } + } + + internal static bool TryReadStringProperty(ref Utf8JsonReader reader, JsonEncodedText propertyName, [NotNullWhen(true)] out string? value) + { + if (!reader.ValueTextEquals(propertyName.EncodedUtf8Bytes)) + { + value = default; + return false; + } + + reader.Read(); + value = reader.GetString()!; + return true; + } + + internal static void WriteProblemDetails(Utf8JsonWriter writer, ProblemDetails value, JsonSerializerOptions options) + { + if (value.Type != null) + { + writer.WriteString(Type, value.Type); + } + + if (value.Title != null) + { + writer.WriteString(Title, value.Title); + } + + if (value.Status != null) + { + writer.WriteNumber(Status, value.Status.Value); + } + + if (value.Detail != null) + { + writer.WriteString(Detail, value.Detail); + } + + if (value.Instance != null) + { + writer.WriteString(Instance, value.Instance); + } + + foreach (var kvp in value.Extensions) + { + writer.WritePropertyName(kvp.Key); + JsonSerializer.Serialize(writer, kvp.Value, kvp.Value?.GetType() ?? typeof(object), options); + } + } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/Handlers/ProblemDetailsHttpMessageHandler.cs b/src/Enclave.Sdk/Handlers/ProblemDetailsHttpMessageHandler.cs new file mode 100644 index 0000000..5bbedfb --- /dev/null +++ b/src/Enclave.Sdk/Handlers/ProblemDetailsHttpMessageHandler.cs @@ -0,0 +1,26 @@ +using System.Net.Http.Json; +using Enclave.Sdk.Api.Exceptions; + +namespace Enclave.Sdk.Api.Handlers; + +internal sealed class ProblemDetailsHttpMessageHandler : DelegatingHandler +{ + public ProblemDetailsHttpMessageHandler() +#pragma warning disable CA2000 // Dispose objects before losing scope + : base(new HttpClientHandler()) { } +#pragma warning restore CA2000 // Dispose objects before losing scope + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var response = await base.SendAsync(request, ct); + + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (mediaType != null && mediaType.Equals("application/problem+json", StringComparison.OrdinalIgnoreCase)) + { + var problemDetails = await response.Content.ReadFromJsonAsync(Constants.JsonSerializerOptions, ct) ?? new ProblemDetails(); + throw new ProblemDetailsException(problemDetails, response); + } + + return response; + } +} \ No newline at end of file diff --git a/src/Enclave.Sdk/credentials-example.json b/src/Enclave.Sdk/credentials-example.json new file mode 100644 index 0000000..0497e09 --- /dev/null +++ b/src/Enclave.Sdk/credentials-example.json @@ -0,0 +1,4 @@ +{ + "personalAccessToken": "", + "baseUrl": "" +} \ No newline at end of file diff --git a/tests/Enclave.Sdk.Api.Tests/Clients/OrganisationClientTests.cs b/tests/Enclave.Sdk.Api.Tests/Clients/OrganisationClientTests.cs new file mode 100644 index 0000000..1fae9a2 --- /dev/null +++ b/tests/Enclave.Sdk.Api.Tests/Clients/OrganisationClientTests.cs @@ -0,0 +1,294 @@ +using System.Text.Json; +using Enclave.Sdk.Api.Clients; +using Enclave.Sdk.Api.Data; +using Enclave.Sdk.Api.Data.Account; +using Enclave.Sdk.Api.Data.Organisations; +using Enclave.Sdk.Api.Data.PatchModel; +using FluentAssertions; +using NUnit.Framework; +using WireMock.FluentAssertions; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Enclave.Sdk.Api.Tests.Clients; + +public class OrganisationClientTests +{ + private OrganisationClient _organisationClient; + private WireMockServer _server; + private string _orgRoute; + private JsonSerializerOptions _serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [SetUp] + public void Setup() + { + _server = WireMockServer.Start(); + + var httpClient = new HttpClient + { + BaseAddress = new Uri(_server.Urls[0]), + }; + + var currentOrganisation = new AccountOrganisation + { + OrgId = OrganisationId.New(), + OrgName = "TestName", + Role = UserOrganisationRole.Admin, + }; + + _orgRoute = $"/org/{currentOrganisation.OrgId}"; + + _organisationClient = new OrganisationClient(httpClient, currentOrganisation); + } + + [Test] + public async Task Should_return_a_detailed_organisation_model_when_calling_GetAsync() + { + // Arrange + var org = new Organisation + { + Id = OrganisationId.New(), + }; + + _server + .Given(Request.Create().WithPath(_orgRoute).UsingGet()) + .RespondWith( + Response.Create() + .WithSuccess() + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(org, _serializerOptions))); + + // Act + var result = await _organisationClient.GetAsync(); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(org.Id); + } + + [Test] + public async Task Should_return_a_detailed_organisation_model_when_updating_with_UpdateAsync() + { + // Arrange + var org = new Organisation + { + Id = OrganisationId.New(), + Website = "newWebsite", + }; + + _server + .Given(Request.Create().WithPath(_orgRoute).UsingPatch()) + .RespondWith( + Response.Create() + .WithSuccess() + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(org, _serializerOptions))); + + var patchModel = new PatchBuilder().Set(x => x.Website, "newWebsite"); + + // Act + var result = await _organisationClient.UpdateAsync(patchModel); + + // Assert + result.Should().NotBeNull(); + result.Website.Should().Be(org.Website); + } + + [Test] + public async Task Should_make_a_call_to_api_when_updating_with_UpdateAsync() + { + // Arrange + var org = new Organisation + { + Id = OrganisationId.New(), + Website = "newWebsite", + }; + + _server + .Given(Request.Create().WithPath(_orgRoute).UsingPatch()) + .RespondWith( + Response.Create() + .WithSuccess() + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(org, _serializerOptions))); + + var patchModel = new PatchBuilder().Set(x => x.Website, "newWebsite"); + + // Act + var result = await _organisationClient.UpdateAsync(patchModel); + + // Assert + _server.Should().HaveReceivedACall().AtUrl($"{_server.Urls[0]}{_orgRoute}"); + } + + [Test] + public async Task Should_return_a_list_of_organisation_users_when_calling_GetOrganisationUsersAsync() + { + // Arrange + var orgUsers = new OrganisationUsersTopLevel + { + Users = new List() + { + new OrganisationUser + { + Id = AccountId.New(), + FirstName = "testUser1", + LastName = "lastName1", + }, + new OrganisationUser + { + Id = AccountId.New(), + FirstName = "testUser2", + LastName = "lastName2", + }, + }, + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/users").UsingGet()) + .RespondWith( + Response.Create() + .WithSuccess() + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(orgUsers, _serializerOptions))); + + // Act + var result = await _organisationClient.GetOrganisationUsersAsync(); + + // Assert + result.Count.Should().Be(2); + } + + [Test] + public async Task Should_make_a_call_to_api_when_calling_GetOrganisationUsersAsync() + { + // Arrange + var orgUsers = new OrganisationUsersTopLevel + { + Users = new List(), + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/users").UsingGet()) + .RespondWith( + Response.Create() + .WithSuccess() + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(orgUsers, _serializerOptions))); + + // Act + var result = await _organisationClient.GetOrganisationUsersAsync(); + + // Assert + _server.Should().HaveReceivedACall().AtUrl($"{_server.Urls[0]}{_orgRoute}/users"); + } + + [Test] + public async Task Should_make_a_call_to_api_when_calling_RemoveUserAsync() + { + // Arrange + var accountId = "test"; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/users/{accountId}").UsingDelete()) + .RespondWith( + Response.Create() + .WithSuccess()); + // Act + await _organisationClient.RemoveUserAsync(accountId); + + // Assert + _server.Should().HaveReceivedACall().AtUrl($"{_server.Urls[0]}{_orgRoute}/users/{accountId}"); + } + + [Test] + public async Task Should_return_list_of_pending_invites_when_calling_GetPendingInvitesAsync() + { + // Arrange + var invites = new OrganisationPendingInvites() + { + Invites = new List + { + new OrganisationInvite { EmailAddress = "testEmail1" }, + new OrganisationInvite { EmailAddress = "testEmail2" }, + }, + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/invites").UsingGet()) + .RespondWith( + Response.Create() + .WithSuccess() + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(invites, _serializerOptions))); + + // Act + var result = await _organisationClient.GetPendingInvitesAsync(); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(2); + } + + [Test] + public async Task Should_make_a_call_to_api_when_calling_GetPendingInvitesAsync() + { + // Arrange + var invites = new OrganisationPendingInvites() + { + Invites = new List(), + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/invites").UsingGet()) + .RespondWith( + Response.Create() + .WithSuccess() + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(invites, _serializerOptions))); + + // Act + var result = await _organisationClient.GetPendingInvitesAsync(); + + // Assert + _server.Should().HaveReceivedACall().AtUrl($"{_server.Urls[0]}{_orgRoute}/invites"); + } + + [Test] + public async Task Should_make_a_call_to_api_when_calling_InviteUserAsync() + { + // Arrange + _server + .Given(Request.Create().WithPath($"{_orgRoute}/invites").UsingPost()) + .RespondWith( + Response.Create() + .WithSuccess()); + + // Act + await _organisationClient.InviteUserAsync("testEmailAddress"); + + // Assert + _server.Should().HaveReceivedACall().AtUrl($"{_server.Urls[0]}{_orgRoute}/invites"); + } + + [Test] + public async Task Should_make_a_call_to_api_when_calling_CancelInviteAync() + { + // Arrange + _server + .Given(Request.Create().WithPath($"{_orgRoute}/invites").UsingDelete()) + .RespondWith( + Response.Create() + .WithSuccess()); + + // Act + await _organisationClient.CancelInviteAync("testEmailAddress"); + + // Assert + _server.Should().HaveReceivedACall().AtUrl($"{_server.Urls[0]}{_orgRoute}/invites"); + } +} diff --git a/tests/Enclave.Sdk.Api.Tests/Clients/TagClientTests.cs b/tests/Enclave.Sdk.Api.Tests/Clients/TagClientTests.cs new file mode 100644 index 0000000..5675c01 --- /dev/null +++ b/tests/Enclave.Sdk.Api.Tests/Clients/TagClientTests.cs @@ -0,0 +1,209 @@ +using System.Text.Json; +using Enclave.Sdk.Api.Clients; +using Enclave.Sdk.Api.Data.Pagination; +using Enclave.Sdk.Api.Data.Tags; +using FluentAssertions; +using NUnit.Framework; +using WireMock.FluentAssertions; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Enclave.Sdk.Api.Tests.Clients; + +public class TagClientTests +{ + private TagsClient _tagClient; + private WireMockServer _server; + private string _orgRoute; + private JsonSerializerOptions _serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [SetUp] + public void Setup() + { + _server = WireMockServer.Start(); + + var httpClient = new HttpClient + { + BaseAddress = new Uri(_server.Urls[0]), + }; + + var orgId = "testId"; + + _orgRoute = $"/org/{orgId}"; + + _tagClient = new TagsClient(httpClient, _orgRoute); + } + + [Test] + public async Task Should_return_a_list_of_tags_in_pagination_format() + { + // Arrange + var responseModel = new PaginatedResponseModel + { + Items = new List + { + new TagItem { Tag = "tag1", Keys = 12, DnsRecords = 1, Policies = 0, Systems = 3 }, + new TagItem { Tag = "tag2", Keys = 13, DnsRecords = 0, Policies = 43, Systems = 0 }, + }, + Links = new PaginationLinks(), + Metadata = new PaginationMetadata(), + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/tags").UsingGet()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithBody(JsonSerializer.Serialize(responseModel, _serializerOptions))); + + // Act + var result = await _tagClient.GetAsync(); + + // Assert + result.Should().NotBeNull(); + result.Items.Should().NotBeNull(); + } + + [Test] + public async Task Should_make_call_to_api_with_search_queryString() + { + // Arrange + var searchTerm = "test"; + + var responseModel = new PaginatedResponseModel + { + Items = new List(), + Links = new PaginationLinks(), + Metadata = new PaginationMetadata(), + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/tags").UsingGet()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithBody(JsonSerializer.Serialize(responseModel, _serializerOptions))); + + // Act + var result = await _tagClient.GetAsync(searchTerm: searchTerm); + + // Assert + _server.Should().HaveReceivedACall().AtAbsoluteUrl($"{_server.Urls[0]}{_orgRoute}/tags?search={searchTerm}"); + } + + [Test] + public async Task Should_make_call_to_api_with_sort_queryString() + { + // Arrange + var sortEnum = TagQuerySortOrder.Alphabetical; + + var responseModel = new PaginatedResponseModel + { + Items = new List(), + Links = new PaginationLinks(), + Metadata = new PaginationMetadata(), + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/tags").UsingGet()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithBody(JsonSerializer.Serialize(responseModel, _serializerOptions))); + + // Act + var result = await _tagClient.GetAsync(sortOrder: sortEnum); + + // Assert + _server.Should().HaveReceivedACall().AtAbsoluteUrl($"{_server.Urls[0]}{_orgRoute}/tags?sort={sortEnum}"); + } + + [Test] + public async Task Should_make_call_to_api_with_page_queryString() + { + // Arrange + var pageNumber = 1; + + var responseModel = new PaginatedResponseModel + { + Items = new List(), + Links = new PaginationLinks(), + Metadata = new PaginationMetadata(), + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/tags").UsingGet()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithBody(JsonSerializer.Serialize(responseModel, _serializerOptions))); + + // Act + var result = await _tagClient.GetAsync(pageNumber: pageNumber); + + // Assert + _server.Should().HaveReceivedACall().AtAbsoluteUrl($"{_server.Urls[0]}{_orgRoute}/tags?page={pageNumber}"); + } + + [Test] + public async Task Should_make_call_to_api_with_per_page_queryString() + { + // Arrange + var perPage = 1; + + var responseModel = new PaginatedResponseModel + { + Items = new List(), + Links = new PaginationLinks(), + Metadata = new PaginationMetadata(), + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/tags").UsingGet()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithBody(JsonSerializer.Serialize(responseModel, _serializerOptions))); + + // Act + var result = await _tagClient.GetAsync(perPage: perPage); + + // Assert + _server.Should().HaveReceivedACall().AtAbsoluteUrl($"{_server.Urls[0]}{_orgRoute}/tags?per_page={perPage}"); + } + + [Test] + public async Task Should_make_call_to_api_with_all_queryStrings() + { + // Arrange + var searchTerm = "test"; + var sortEnum = TagQuerySortOrder.Alphabetical; + var perPage = 1; + var pageNumber = 1; + + var responseModel = new PaginatedResponseModel + { + Items = new List(), + Links = new PaginationLinks(), + Metadata = new PaginationMetadata(), + }; + + _server + .Given(Request.Create().WithPath($"{_orgRoute}/tags").UsingGet()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithBody(JsonSerializer.Serialize(responseModel, _serializerOptions))); + + // Act + var result = await _tagClient.GetAsync(searchTerm: searchTerm, sortOrder: sortEnum, pageNumber: pageNumber, perPage: perPage); + + // Assert + _server.Should().HaveReceivedACall() + .AtAbsoluteUrl($"{_server.Urls[0]}{_orgRoute}/tags?search={searchTerm}&sort={sortEnum}&page={pageNumber}&per_page={perPage}"); + } +} \ No newline at end of file diff --git a/tests/Enclave.Sdk.Api.Tests/Enclave.Sdk.Api.Tests.csproj b/tests/Enclave.Sdk.Api.Tests/Enclave.Sdk.Api.Tests.csproj new file mode 100644 index 0000000..149c637 --- /dev/null +++ b/tests/Enclave.Sdk.Api.Tests/Enclave.Sdk.Api.Tests.csproj @@ -0,0 +1,25 @@ + + + + 10.0 + net6.0 + enable + disable + 0c261991-ae7a-4598-9650-7f05d2939f98 + + + + + + + + + + + + + + + + + diff --git a/tests/Enclave.Sdk.Api.Tests/EnclaveClientTests.cs b/tests/Enclave.Sdk.Api.Tests/EnclaveClientTests.cs new file mode 100644 index 0000000..0091ac5 --- /dev/null +++ b/tests/Enclave.Sdk.Api.Tests/EnclaveClientTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Enclave.Sdk.Api.Data; +using Enclave.Sdk.Api.Data.Account; +using FluentAssertions; +using NUnit.Framework; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Enclave.Sdk.Api.Tests; + +public class EnclaveClientTests +{ + private EnclaveClient _client; + private WireMockServer _server; + + private readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [SetUp] + public void Setup() + { + _server = WireMockServer.Start(); + + var enclaveSettings = new EnclaveClientOptions + { + BaseUrl = _server.Urls[0], + }; + + _client = new EnclaveClient(enclaveSettings); + } + + [Test] + public async Task Should_return_list_of_orgs_when_calling_GetOrganisationsAsync() + { + // Arrange + var accountOrg = new AccountOrganisationTopLevel + { + Orgs = new List + { + new AccountOrganisation + { + OrgId = OrganisationId.New(), + OrgName = "TestName", + Role = UserOrganisationRole.Admin, + }, + }, + }; + + _server + .Given(Request.Create().WithPath("/account/orgs").UsingGet()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithBody(JsonSerializer.Serialize(accountOrg, _serializerOptions))); + + // Act + var result = await _client.GetOrganisationsAsync(); + + // Assert + result.FirstOrDefault().OrgId.Should().Be(accountOrg.Orgs.FirstOrDefault().OrgId); + } + + [Test] + public async Task Should_throw_invalid_operation_exception_if_response_does_not_contain_an_organisation_model() + { + // Arrange + _server + .Given(Request.Create().WithPath("/account/orgs").UsingGet()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithBody("null")); + + // Assert + await _client.Invoking(c => c.GetOrganisationsAsync()).Should().ThrowAsync(); + } +} \ No newline at end of file diff --git a/tests/Enclave.Sdk.Api.Tests/GlobalSuppressions.cs b/tests/Enclave.Sdk.Api.Tests/GlobalSuppressions.cs new file mode 100644 index 0000000..aa8ddbd --- /dev/null +++ b/tests/Enclave.Sdk.Api.Tests/GlobalSuppressions.cs @@ -0,0 +1,9 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "We are using variables for tests they do not need to be disposed at end of scope", Scope = "module")] +[assembly: SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "unit test methods use underscores for readability", Scope = "module")]