diff --git a/src/Umbraco.Cms.ManagementApi/Configuration/ConfigureMvcOptions.cs b/src/Umbraco.Cms.ManagementApi/Configuration/ConfigureMvcOptions.cs new file mode 100644 index 0000000000..03963c22a3 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Configuration/ConfigureMvcOptions.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.New.Cms.Web.Common.Routing; + +namespace Umbraco.Cms.ManagementApi.Configuration; + +public class ConfigureMvcOptions : IConfigureOptions +{ + private readonly IOptions _globalSettings; + + public ConfigureMvcOptions(IOptions globalSettings) + { + _globalSettings = globalSettings; + } + + public void Configure(MvcOptions options) + { + // Replace the BackOfficeToken in routes. + + var backofficePath = _globalSettings.Value.UmbracoPath.TrimStart(Constants.CharArrays.TildeForwardSlash); + options.Conventions.Add(new UmbracoBackofficeToken(Constants.Web.AttributeRouting.BackOfficeToken, backofficePath)); + + } +} diff --git a/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs b/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs index f0921f2244..095b4e39e3 100644 --- a/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Versioning; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using NSwag.AspNetCore; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.ManagementApi.Configuration; using Umbraco.Cms.ManagementApi.DependencyInjection; using Umbraco.Cms.Web.Common.ApplicationBuilder; using Umbraco.Extensions; -using Umbraco.New.Cms.Web.Common.Routing; +using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Cms.ManagementApi; @@ -28,6 +29,8 @@ public class ManagementApiComposer : IComposer public void Compose(IUmbracoBuilder builder) { + // TODO Should just call a single extension method that can be called fromUmbracoTestServerTestBase too, instead of calling this method + IServiceCollection services = builder.Services; builder @@ -59,17 +62,8 @@ public class ManagementApiComposer : IComposer options.AddApiVersionParametersWhenVersionNeutral = true; options.AssumeDefaultVersionWhenUnspecified = true; }); - - // Not super happy with this, but we need to know the UmbracoPath when registering the controller - // To be able to replace the route template token - GlobalSettings? globalSettings = - builder.Config.GetSection(Constants.Configuration.ConfigGlobal).Get(); - var backofficePath = globalSettings.UmbracoPath.TrimStart(Constants.CharArrays.TildeForwardSlash); - - services.AddControllers(options => - { - options.Conventions.Add(new UmbracoBackofficeToken(Constants.Web.AttributeRouting.BackOfficeToken, backofficePath)); - }); + services.AddControllers(); + builder.Services.ConfigureOptions(); builder.Services.Configure(options => { @@ -99,31 +93,46 @@ public class ManagementApiComposer : IComposer applicationBuilder => { IServiceProvider provider = applicationBuilder.ApplicationServices; - GlobalSettings? settings = provider.GetRequiredService>().Value; - IHostingEnvironment hostingEnvironment = provider.GetRequiredService(); - var officePath = settings.GetBackOfficePath(hostingEnvironment); + IWebHostEnvironment webHostEnvironment = provider.GetRequiredService(); - // serve documents (same as app.UseSwagger()) - applicationBuilder.UseOpenApi(config => + if (!webHostEnvironment.IsProduction()) { - config.Path = $"{officePath}/swagger/{{documentName}}/swagger.json"; - }); + GlobalSettings? settings = provider.GetRequiredService>().Value; + IHostingEnvironment hostingEnvironment = provider.GetRequiredService(); + var officePath = settings.GetBackOfficePath(hostingEnvironment); + // serve documents (same as app.UseSwagger()) + applicationBuilder.UseOpenApi(config => + { + config.Path = $"{officePath}/swagger/{{documentName}}/swagger.json"; + }); - // Serve Swagger UI - applicationBuilder.UseSwaggerUi3(config => - { - config.Path = officePath + "/swagger"; - config.SwaggerRoutes.Clear(); - var swaggerPath = $"{officePath}/swagger/{ApiAllName}/swagger.json"; - config.SwaggerRoutes.Add(new SwaggerUi3Route(ApiAllName, swaggerPath)); - }); + // Serve Swagger UI + applicationBuilder.UseSwaggerUi3(config => + { + config.Path = officePath + "/swagger"; + config.SwaggerRoutes.Clear(); + var swaggerPath = $"{officePath}/swagger/{ApiAllName}/swagger.json"; + config.SwaggerRoutes.Add(new SwaggerUi3Route(ApiAllName, swaggerPath)); + }); + } }, applicationBuilder => { + IServiceProvider provider = applicationBuilder.ApplicationServices; + applicationBuilder.UseEndpoints(endpoints => { + GlobalSettings? settings = provider.GetRequiredService>().Value; + IHostingEnvironment hostingEnvironment = provider.GetRequiredService(); + var officePath = settings.GetBackOfficePath(hostingEnvironment); // Maps attribute routed controllers. endpoints.MapControllers(); + + // Serve contract + endpoints.MapGet($"{officePath}/api/openapi.json",async context => + { + await context.Response.SendFileAsync(new EmbeddedFileProvider(this.GetType().Assembly).GetFileInfo("OpenApi.json")); + }); }); } )); diff --git a/src/Umbraco.Cms.ManagementApi/OpenApi.json b/src/Umbraco.Cms.ManagementApi/OpenApi.json new file mode 100644 index 0000000000..47bc9e0fbb --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/OpenApi.json @@ -0,0 +1,552 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Umbraco Backoffice API", + "description": "This shows all APIs available in this version of Umbraco - Including all the legacy apis that is available for backward compatibility", + "version": "All" + }, + "servers": [ + { + "url": "https://localhost:44331" + } + ], + "paths": { + "/umbraco/api/v1/install/settings": { + "get": { + "tags": [ + "Install" + ], + "operationId": "SettingsInstall_Settings", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "428": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallSettingsViewModel" + } + } + } + } + } + } + }, + "/umbraco/api/v1/install/setup": { + "post": { + "tags": [ + "Install" + ], + "operationId": "SetupInstall_Setup", + "requestBody": { + "x-name": "installData", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallViewModel" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "428": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "200": { + "description": "" + } + } + } + }, + "/umbraco/api/v1/install/validateDatabase": { + "post": { + "tags": [ + "Install" + ], + "operationId": "ValidateDatabaseInstall_ValidateDatabase", + "requestBody": { + "x-name": "viewModel", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseInstallViewModel" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "200": { + "description": "" + } + } + } + }, + "/umbraco/api/v1/upgrade/authorize": { + "post": { + "tags": [ + "Upgrade" + ], + "operationId": "AuthorizeUpgrade_Authorize", + "responses": { + "200": { + "description": "" + }, + "428": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/umbraco/api/v1/upgrade/settings": { + "get": { + "tags": [ + "Upgrade" + ], + "operationId": "SettingsUpgrade_Settings", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradeSettingsViewModel" + } + } + } + }, + "428": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/umbraco/api/v1/server/status": { + "get": { + "tags": [ + "Server" + ], + "operationId": "StatusServer_Get", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerStatusViewModel" + } + } + } + } + } + } + }, + "/umbraco/api/v1/server/version": { + "get": { + "tags": [ + "Server" + ], + "operationId": "VersionServer_Get", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionViewModel" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ProblemDetails": { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + } + }, + "InstallSettingsViewModel": { + "type": "object", + "additionalProperties": false, + "properties": { + "user": { + "$ref": "#/components/schemas/UserSettingsViewModel" + }, + "databases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DatabaseSettingsViewModel" + } + } + } + }, + "UserSettingsViewModel": { + "type": "object", + "additionalProperties": false, + "properties": { + "minCharLength": { + "type": "integer", + "format": "int32" + }, + "minNonAlphaNumericLength": { + "type": "integer", + "format": "int32" + }, + "consentLevels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConsentLevelViewModel" + } + } + } + }, + "ConsentLevelViewModel": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/components/schemas/TelemetryLevel" + }, + "description": { + "type": "string" + } + } + }, + "TelemetryLevel": { + "type": "string", + "description": "", + "x-enumNames": [ + "Minimal", + "Basic", + "Detailed" + ], + "enum": [ + "Minimal", + "Basic", + "Detailed" + ] + }, + "DatabaseSettingsViewModel": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "guid" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "displayName": { + "type": "string" + }, + "defaultDatabaseName": { + "type": "string" + }, + "providerName": { + "type": "string" + }, + "isConfigured": { + "type": "boolean" + }, + "requiresServer": { + "type": "boolean" + }, + "serverPlaceholder": { + "type": "string" + }, + "requiresCredentials": { + "type": "boolean" + }, + "supportsIntegratedAuthentication": { + "type": "boolean" + }, + "requiresConnectionTest": { + "type": "boolean" + } + } + }, + "InstallViewModel": { + "type": "object", + "additionalProperties": false, + "required": [ + "user", + "database" + ], + "properties": { + "user": { + "$ref": "#/components/schemas/UserInstallViewModel" + }, + "database": { + "$ref": "#/components/schemas/DatabaseInstallViewModel" + }, + "telemetryLevel": { + "$ref": "#/components/schemas/TelemetryLevel" + } + } + }, + "UserInstallViewModel": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "email", + "password" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 0 + }, + "email": { + "type": "string", + "format": "email", + "minLength": 1 + }, + "password": { + "type": "string", + "minLength": 1 + }, + "subscribeToNewsletter": { + "type": "boolean" + } + } + }, + "DatabaseInstallViewModel": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "providerName" + ], + "properties": { + "id": { + "type": "string", + "format": "guid", + "minLength": 1 + }, + "providerName": { + "type": "string", + "minLength": 1 + }, + "server": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "useIntegratedAuthentication": { + "type": "boolean" + }, + "connectionString": { + "type": "string", + "nullable": true + } + } + }, + "UpgradeSettingsViewModel": { + "type": "object", + "additionalProperties": false, + "properties": { + "currentState": { + "type": "string" + }, + "newState": { + "type": "string" + }, + "newVersion": { + "type": "string" + }, + "oldVersion": { + "type": "string" + }, + "reportUrl": { + "type": "string" + } + } + }, + "ServerStatusViewModel": { + "type": "object", + "additionalProperties": false, + "properties": { + "serverStatus": { + "$ref": "#/components/schemas/RuntimeLevel" + } + } + }, + "RuntimeLevel": { + "type": "string", + "description": "Describes the levels in which the runtime can run.\n ", + "x-enumNames": [ + "Unknown", + "Boot", + "Install", + "Upgrade", + "Run", + "BootFailed" + ], + "enum": [ + "Unknown", + "Boot", + "Install", + "Upgrade", + "Run", + "BootFailed" + ] + }, + "VersionViewModel": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "string" + } + } + } + } + }, + "tags": [ + { + "name": "Upgrade" + }, + { + "name": "Server" + }, + { + "name": "Install" + } + ] +} diff --git a/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj b/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj index fb8c327221..090ae68e25 100644 --- a/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj +++ b/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj @@ -26,4 +26,9 @@ all + + + + + diff --git a/tests/Umbraco.Tests.Integration/NewBackoffice/OpenAPIContractTest.cs b/tests/Umbraco.Tests.Integration/NewBackoffice/OpenAPIContractTest.cs new file mode 100644 index 0000000000..de5fcc6b3b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/NewBackoffice/OpenAPIContractTest.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Tests.Integration.TestServerTest; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.Integration.NewBackoffice; + +// We only run this test in release because the schema looks different depending if it's built against release or debug. +// XML summaries is included in the description of a response model in release, but not debug mode. +#if DEBUG +[Ignore("This test runs only in release")] +#endif +[TestFixture] +public class OpenAPIContractTest : UmbracoTestServerTestBase +{ + + private GlobalSettings GlobalSettings => GetRequiredService>().Value; + + private IHostingEnvironment HostingEnvironment => GetRequiredService(); + + [Test] + public async Task Validate_OpenApi_Contract_is_implemented() + { + string[] keysToIgnore = { "servers" }; + + var officePath = GlobalSettings.GetBackOfficePath(HostingEnvironment); + + var urlToContract = $"{officePath}/api/openapi.json"; + var swaggerPath = $"{officePath}/swagger/All/swagger.json"; + var apiContract = JObject.Parse(await Client.GetStringAsync(urlToContract)); + + var generatedJsonString = await Client.GetStringAsync(swaggerPath); + var mergedContract = JObject.Parse(generatedJsonString); + var originalGeneratedContract = JObject.Parse(generatedJsonString); + + + mergedContract.Merge(apiContract, new JsonMergeSettings + { + MergeArrayHandling = MergeArrayHandling.Merge + }); + + foreach (var key in keysToIgnore) + { + originalGeneratedContract.Remove(key); + mergedContract.Remove(key); + } + + Assert.AreEqual(originalGeneratedContract, mergedContract, $"Generated API do not respect the contract:{Environment.NewLine}Expected:{Environment.NewLine}{originalGeneratedContract.ToString(Formatting.Indented)}{Environment.NewLine}{Environment.NewLine}Actual:{Environment.NewLine}{mergedContract.ToString(Formatting.Indented)}"); + } +} diff --git a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs index 823754bdfc..92909149d1 100644 --- a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs +++ b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs @@ -19,6 +19,9 @@ using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; +using Umbraco.Cms.ManagementApi; +using Umbraco.Cms.ManagementApi.Configuration; +using Umbraco.Cms.ManagementApi.Controllers.Install; using Umbraco.Cms.Persistence.Sqlite; using Umbraco.Cms.Persistence.SqlServer; using Umbraco.Cms.Tests.Common.Testing; @@ -26,7 +29,6 @@ using Umbraco.Cms.Tests.Integration.DependencyInjection; using Umbraco.Cms.Tests.Integration.Testing; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Controllers; -using Umbraco.Cms.Web.Common.Hosting; using Umbraco.Cms.Web.Website.Controllers; using Umbraco.Extensions; @@ -238,6 +240,9 @@ namespace Umbraco.Cms.Tests.Integration.TestServerTest // Adds Umbraco.Tests.Integration mvcBuilder.AddApplicationPart(typeof(UmbracoTestServerTestBase).Assembly); + + // Adds Umbraco.Tests.Integration + mvcBuilder.AddApplicationPart(typeof(InstallControllerBase).Assembly); }) .AddWebServer() .AddWebsite() @@ -245,6 +250,8 @@ namespace Umbraco.Cms.Tests.Integration.TestServerTest .AddUmbracoSqliteSupport() .AddTestServices(TestHelper); // This is the important one! + new ManagementApiComposer().Compose(builder); + CustomTestSetup(builder); builder.Build(); } @@ -254,6 +261,7 @@ namespace Umbraco.Cms.Tests.Integration.TestServerTest /// protected virtual void ConfigureTestServices(IServiceCollection services) { + } protected void Configure(IApplicationBuilder app) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 687f9dfca0..ae197047fe 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -105,6 +105,7 @@ + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/InstallAreaRoutesTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/InstallAreaRoutesTests.cs index 3e4c930947..c5a8eb5201 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/InstallAreaRoutesTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/InstallAreaRoutesTests.cs @@ -81,7 +81,8 @@ public class InstallAreaRoutesTests var route = endpoints.DataSources.First(); Assert.AreEqual(1, route.Endpoints.Count); - Assert.AreEqual("install/{controller?}/{action?} HTTP: GET", route.Endpoints[0].ToString()); + var routeEndpoint = (RouteEndpoint)route.Endpoints[0]; + Assert.AreEqual("install/{controller?}/{action?}", routeEndpoint.RoutePattern.RawText); } private InstallAreaRoutes GetInstallAreaRoutes(RuntimeLevel level) =>