From 5ce876f9d10f05d06a62bb5eeda0f5f3bcfa6be6 Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Wed, 29 Oct 2025 10:35:13 +0100 Subject: [PATCH 01/12] add feature flag --- src/Altinn.App.Core/Features/FeatureFlags.cs | 7 +++++++ src/Altinn.App.Core/Internal/App/FrontendFeatures.cs | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Altinn.App.Core/Features/FeatureFlags.cs b/src/Altinn.App.Core/Features/FeatureFlags.cs index 402dbc4509..a5373e2ff2 100644 --- a/src/Altinn.App.Core/Features/FeatureFlags.cs +++ b/src/Altinn.App.Core/Features/FeatureFlags.cs @@ -12,4 +12,11 @@ public static class FeatureFlags /// return validation errors in the response body instead of a string. /// public const string JsonObjectInDataResponse = "JsonObjectInDataResponse"; + + // TODO: write a better summary here + /// + /// Enabling this feature changes backend endpoint used for layouts to + /// add instance identifier. + /// + public const string AddInstanceIdentifierToLayoutRequests = "AddInstanceIdentifierToLayoutRequests"; } diff --git a/src/Altinn.App.Core/Internal/App/FrontendFeatures.cs b/src/Altinn.App.Core/Internal/App/FrontendFeatures.cs index 45c975a29f..e19f9037a3 100644 --- a/src/Altinn.App.Core/Internal/App/FrontendFeatures.cs +++ b/src/Altinn.App.Core/Internal/App/FrontendFeatures.cs @@ -26,6 +26,15 @@ public FrontendFeatures(IFeatureManager featureManager) { _features.Add("jsonObjectInDataResponse", false); } + + if (featureManager.IsEnabledAsync(FeatureFlags.AddInstanceIdentifierToLayoutRequests).Result) + { + _features.Add("addInstanceIdentifierToLayoutRequests", true); + } + else + { + _features.Add("addInstanceIdentifierToLayoutRequests", false); + } } /// From 3218e40370bc6681112e7f173f818fa5ac1bcfeb Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Wed, 29 Oct 2025 16:15:36 +0100 Subject: [PATCH 02/12] add service using `AsyncLocal` for instance context across request --- .../Controllers/ResourceController.cs | 31 ++++++++++++++++++- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Implementation/InstanceContext.cs | 30 ++++++++++++++++++ .../Internal/App/IInstanceContext.cs | 17 ++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/Altinn.App.Core/Implementation/InstanceContext.cs create mode 100644 src/Altinn.App.Core/Internal/App/IInstanceContext.cs diff --git a/src/Altinn.App.Api/Controllers/ResourceController.cs b/src/Altinn.App.Api/Controllers/ResourceController.cs index e38ad0cc8e..e142e2ac90 100644 --- a/src/Altinn.App.Api/Controllers/ResourceController.cs +++ b/src/Altinn.App.Api/Controllers/ResourceController.cs @@ -11,14 +11,17 @@ namespace Altinn.App.Api.Controllers; public class ResourceController : ControllerBase { private readonly IAppResources _appResourceService; + private readonly IInstanceContext _instanceContext; /// /// Initializes a new instance of the class /// /// The execution service - public ResourceController(IAppResources appResourcesService) + /// The instance context + public ResourceController(IAppResources appResourcesService, IInstanceContext instanceContext) { _appResourceService = appResourcesService; + _instanceContext = instanceContext; } /// @@ -71,6 +74,32 @@ public ActionResult GetLayouts(string org, string app, string id) return Ok(layouts); } + /// + /// Endpoint for layouts with instance context + /// + /// + /// + /// + /// + /// + /// + [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/plain")] + [HttpGet] + [Route("{org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}")] + public ActionResult GetInstanceLayouts( + string org, + string app, + int instanceOwnerPartyId, + string instanceId, + string layoutSetId + ) + { + _instanceContext.InstanceId = instanceId; + _instanceContext.InstanceOwnerPartyId = instanceOwnerPartyId; + string layouts = _appResourceService.GetLayoutsForSet(layoutSetId); + return Ok(layouts); + } + /// /// Get the layout settings. /// diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index e463ded3a9..ae1fa0416b 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -169,6 +169,7 @@ IWebHostEnvironment env services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Altinn.App.Core/Implementation/InstanceContext.cs b/src/Altinn.App.Core/Implementation/InstanceContext.cs new file mode 100644 index 0000000000..e369cb0e8d --- /dev/null +++ b/src/Altinn.App.Core/Implementation/InstanceContext.cs @@ -0,0 +1,30 @@ +using Altinn.App.Core.Internal.App; + +namespace Altinn.App.Core.Implementation; + +/// +/// Scoped service to access instance information. +/// +public class InstanceContext : IInstanceContext +{ + private static readonly AsyncLocal _instanceOwnerPartyId = new(); + private static readonly AsyncLocal _instanceId = new(); + + /// + /// The instance id + /// + public string? InstanceId + { + get => _instanceId.Value; + set => _instanceId.Value = value; + } + + /// + /// The party id + /// + public int? InstanceOwnerPartyId + { + get => _instanceOwnerPartyId.Value; + set => _instanceOwnerPartyId.Value = value; + } +} diff --git a/src/Altinn.App.Core/Internal/App/IInstanceContext.cs b/src/Altinn.App.Core/Internal/App/IInstanceContext.cs new file mode 100644 index 0000000000..1cb59ead5b --- /dev/null +++ b/src/Altinn.App.Core/Internal/App/IInstanceContext.cs @@ -0,0 +1,17 @@ +namespace Altinn.App.Core.Internal.App; + +/// +/// Interface for accessing instance context information +/// +public interface IInstanceContext +{ + /// + /// Instance Id + /// + string? InstanceId { get; set; } + + /// + /// Party Id + /// + int? InstanceOwnerPartyId { get; set; } +} From ca3b07d12399fa50944d519833bceb56c68ca41e Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Thu, 6 Nov 2025 11:08:14 +0100 Subject: [PATCH 03/12] Use optional service for custom layouts if implemented by app --- .../Controllers/ResourceController.cs | 26 +++++++++++----- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Implementation/InstanceContext.cs | 30 ------------------- .../Internal/App/ICustomLayoutForInstance.cs | 9 ++++++ .../Internal/App/IInstanceContext.cs | 17 ----------- 5 files changed, 27 insertions(+), 56 deletions(-) delete mode 100644 src/Altinn.App.Core/Implementation/InstanceContext.cs create mode 100644 src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs delete mode 100644 src/Altinn.App.Core/Internal/App/IInstanceContext.cs diff --git a/src/Altinn.App.Api/Controllers/ResourceController.cs b/src/Altinn.App.Api/Controllers/ResourceController.cs index e142e2ac90..646e141340 100644 --- a/src/Altinn.App.Api/Controllers/ResourceController.cs +++ b/src/Altinn.App.Api/Controllers/ResourceController.cs @@ -1,3 +1,4 @@ +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; using Microsoft.AspNetCore.Mvc; @@ -11,17 +12,17 @@ namespace Altinn.App.Api.Controllers; public class ResourceController : ControllerBase { private readonly IAppResources _appResourceService; - private readonly IInstanceContext _instanceContext; + private readonly AppImplementationFactory _appImplementationFactory; /// /// Initializes a new instance of the class /// /// The execution service - /// The instance context - public ResourceController(IAppResources appResourcesService, IInstanceContext instanceContext) + /// The service provider + public ResourceController(IAppResources appResourcesService, IServiceProvider serviceProvider) { _appResourceService = appResourcesService; - _instanceContext = instanceContext; + _appImplementationFactory = serviceProvider.GetRequiredService(); } /// @@ -75,7 +76,8 @@ public ActionResult GetLayouts(string org, string app, string id) } /// - /// Endpoint for layouts with instance context + /// Endpoint for layouts with instance context. + /// Uses ICustomLayoutForInstance if implemented with IAppResources as fallback. /// /// /// @@ -86,7 +88,7 @@ public ActionResult GetLayouts(string org, string app, string id) [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/plain")] [HttpGet] [Route("{org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}")] - public ActionResult GetInstanceLayouts( + public async Task GetInstanceLayouts( string org, string app, int instanceOwnerPartyId, @@ -94,8 +96,16 @@ public ActionResult GetInstanceLayouts( string layoutSetId ) { - _instanceContext.InstanceId = instanceId; - _instanceContext.InstanceOwnerPartyId = instanceOwnerPartyId; + ICustomLayoutForInstance? customLayoutService = _appImplementationFactory.Get(); + if (customLayoutService is not null) + { + string? customLayout = await customLayoutService.GetCustomLayoutForInstance( + layoutSetId, + instanceOwnerPartyId, + Guid.Parse(instanceId) + ); + return Ok(customLayout); + } string layouts = _appResourceService.GetLayoutsForSet(layoutSetId); return Ok(layouts); } diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index ae1fa0416b..e463ded3a9 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -169,7 +169,6 @@ IWebHostEnvironment env services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Altinn.App.Core/Implementation/InstanceContext.cs b/src/Altinn.App.Core/Implementation/InstanceContext.cs deleted file mode 100644 index e369cb0e8d..0000000000 --- a/src/Altinn.App.Core/Implementation/InstanceContext.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Altinn.App.Core.Internal.App; - -namespace Altinn.App.Core.Implementation; - -/// -/// Scoped service to access instance information. -/// -public class InstanceContext : IInstanceContext -{ - private static readonly AsyncLocal _instanceOwnerPartyId = new(); - private static readonly AsyncLocal _instanceId = new(); - - /// - /// The instance id - /// - public string? InstanceId - { - get => _instanceId.Value; - set => _instanceId.Value = value; - } - - /// - /// The party id - /// - public int? InstanceOwnerPartyId - { - get => _instanceOwnerPartyId.Value; - set => _instanceOwnerPartyId.Value = value; - } -} diff --git a/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs b/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs new file mode 100644 index 0000000000..f01a1d0ab0 --- /dev/null +++ b/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs @@ -0,0 +1,9 @@ +using Altinn.App.Core.Features; + +namespace Altinn.App.Core.Internal.App; + +[ImplementableByApps] +public interface ICustomLayoutForInstance +{ + Task GetCustomLayoutForInstance(string layoutSetId, int instanceOwnerPartyId, Guid instanceGuid); +} diff --git a/src/Altinn.App.Core/Internal/App/IInstanceContext.cs b/src/Altinn.App.Core/Internal/App/IInstanceContext.cs deleted file mode 100644 index 1cb59ead5b..0000000000 --- a/src/Altinn.App.Core/Internal/App/IInstanceContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Altinn.App.Core.Internal.App; - -/// -/// Interface for accessing instance context information -/// -public interface IInstanceContext -{ - /// - /// Instance Id - /// - string? InstanceId { get; set; } - - /// - /// Party Id - /// - int? InstanceOwnerPartyId { get; set; } -} From 271ca3849d2b87e7ab636a690957f7216918bb11 Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Thu, 6 Nov 2025 11:22:08 +0100 Subject: [PATCH 04/12] add xml comments --- src/Altinn.App.Api/Controllers/ResourceController.cs | 12 ++++++------ .../Internal/App/ICustomLayoutForInstance.cs | 9 +++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ResourceController.cs b/src/Altinn.App.Api/Controllers/ResourceController.cs index 646e141340..d5d197dc4d 100644 --- a/src/Altinn.App.Api/Controllers/ResourceController.cs +++ b/src/Altinn.App.Api/Controllers/ResourceController.cs @@ -79,12 +79,12 @@ public ActionResult GetLayouts(string org, string app, string id) /// Endpoint for layouts with instance context. /// Uses ICustomLayoutForInstance if implemented with IAppResources as fallback. /// - /// - /// - /// - /// - /// - /// + /// The application owner short name + /// The application name + /// The instance owner party id + /// The instance id + /// The layout set id + /// A collection of FormLayout objects in JSON format. [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/plain")] [HttpGet] [Route("{org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}")] diff --git a/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs b/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs index f01a1d0ab0..2eee82594d 100644 --- a/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs +++ b/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs @@ -2,8 +2,17 @@ namespace Altinn.App.Core.Internal.App; +/// +/// Interface for getting custom layouts for an instance. +/// [ImplementableByApps] public interface ICustomLayoutForInstance { + /// + /// Gets the custom layout + /// + /// The layout set ID + /// The instance owner party ID + /// The instance GUID Task GetCustomLayoutForInstance(string layoutSetId, int instanceOwnerPartyId, Guid instanceGuid); } From 301807c2a0d566f3f52221a2a848c8b6603391b3 Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Mon, 10 Nov 2025 09:40:00 +0100 Subject: [PATCH 05/12] add tests --- .../ResourceController_CustomLayoutTests.cs | 76 +++++++++++++++++++ ...ngeDetection.SaveJsonSwagger.verified.json | 68 +++++++++++++++++ ...ouldNotChange_Unintentionally.verified.txt | 7 +- ...serialize_unmapped_properties.verified.txt | 3 +- .../Internal/App/FrontendFeaturesTest.cs | 5 ++ ...ouldNotChange_Unintentionally.verified.txt | 5 ++ 6 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs diff --git a/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs b/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs new file mode 100644 index 0000000000..208dc3b197 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using System.Text.Json; +using Altinn.App.Api.Tests.Data; +using Altinn.App.Core.Internal.App; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Altinn.App.Api.Tests.Controllers; + +public class ResourceController_CustomLayoutTests : ApiTestBase, IClassFixture> +{ + public ResourceController_CustomLayoutTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) + : base(factory, outputHelper) { } + + private class CustomLayoutForInstance : ICustomLayoutForInstance + { + public Task GetCustomLayoutForInstance(string layoutSetId, int instanceOwnerPartyId, Guid instanceId) + { + return Task.FromResult(instanceId.ToString()); + } + } + + [Fact] + public async Task GetLayoutsForSet_WithCustomLayoutForInstanceService_ReturnsOk() + { + OverrideServicesForThisTest = (services) => + { + services.AddSingleton(); + }; + + string org = "tdd"; + string app = "contributer-restriction"; + int instanceOwnerPartyId = 500600; + Guid instanceGuid = Guid.Parse("cff1cb24-5bc1-4888-8e06-c634753c5144"); + string layoutSetId = "default"; + using HttpClient client = GetRootedUserClient(org, app, 1337, instanceOwnerPartyId); + + TestData.PrepareInstance(org, app, instanceOwnerPartyId, instanceGuid); + var response = await client.GetAsync( + $"/{org}/{app}/instance/{instanceOwnerPartyId}/{instanceGuid}/layouts/{layoutSetId}" + ); + + response.Should().HaveStatusCode(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Be(instanceGuid.ToString()); + } + + [Fact] + public async Task GetLayoutsForSet_WithoutCustomLayoutForInstanceService_ReturnsOk() + { + string org = "tdd"; + string app = "contributer-restriction"; + int instanceOwnerPartyId = 500600; + Guid instanceGuid = Guid.Parse("cff1cb24-5bc1-4888-8e06-c634753c5144"); + string layoutSetId = "default"; + using HttpClient client = GetRootedUserClient(org, app, 1337, instanceOwnerPartyId); + + TestData.PrepareInstance(org, app, instanceOwnerPartyId, instanceGuid); + var response = await client.GetAsync( + $"/{org}/{app}/instance/{instanceOwnerPartyId}/{instanceGuid}/layouts/{layoutSetId}" + ); + + response.Should().HaveStatusCode(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + using var jsonDoc = JsonDocument.Parse(content); + var root = jsonDoc.RootElement; + root.ValueKind.Should().Be(JsonValueKind.Object); + root.TryGetProperty("page", out var pageLayout).Should().BeTrue(); + pageLayout.TryGetProperty("data", out var data).Should().BeTrue(); + data.TryGetProperty("layout", out var layout).Should().BeTrue(); + layout.ValueKind.Should().Be(JsonValueKind.Array); + layout.GetArrayLength().Should().BeGreaterThan(0); + } +} diff --git a/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json b/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json index 6b09d42376..15e99670d9 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json +++ b/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json @@ -5176,6 +5176,74 @@ } } }, + "/{org}/{app}/instance/{instanceOwnerPartyId}/{instanceId}/layouts/{layoutSetId}": { + "get": { + "tags": [ + "Resource" + ], + "summary": "Endpoint for layouts with instance context.\nUses ICustomLayoutForInstance if implemented with IAppResources as fallback.", + "parameters": [ + { + "name": "org", + "in": "path", + "description": "The application owner short name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app", + "in": "path", + "description": "The application name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "instanceOwnerPartyId", + "in": "path", + "description": "The instance owner party id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "instanceId", + "in": "path", + "description": "The instance id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "layoutSetId", + "in": "path", + "description": "The layout set id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/{org}/{app}/api/layoutsettings": { "get": { "tags": [ diff --git a/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 3d74aaaf8f..718f8b1c59 100644 --- a/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -515,13 +515,18 @@ namespace Altinn.App.Api.Controllers [Microsoft.AspNetCore.Mvc.ApiController] public class ResourceController : Microsoft.AspNetCore.Mvc.ControllerBase { - public ResourceController(Altinn.App.Core.Internal.App.IAppResources appResourcesService) { } + public ResourceController(Altinn.App.Core.Internal.App.IAppResources appResourcesService, System.IServiceProvider serviceProvider) { } [Microsoft.AspNetCore.Mvc.HttpGet] [Microsoft.AspNetCore.Mvc.ProducesResponseType(204)] [Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(string), 200, "application/json", new string[0])] [Microsoft.AspNetCore.Mvc.Route("{org}/{app}/api/v1/footer")] public System.Threading.Tasks.Task GetFooterLayout(string org, string app) { } [Microsoft.AspNetCore.Mvc.HttpGet] + [Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(string), 200, "text/plain", new string[0])] + [Microsoft.AspNetCore.Mvc.Route("{org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId" + + "}")] + public System.Threading.Tasks.Task GetInstanceLayouts(string org, string app, int instanceOwnerPartyId, string instanceId, string layoutSetId) { } + [Microsoft.AspNetCore.Mvc.HttpGet] [Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(string), 200, "application/json", new string[0])] [Microsoft.AspNetCore.Mvc.Route("{org}/{app}/api/layoutsets")] public Microsoft.AspNetCore.Mvc.ActionResult GetLayoutSets(string org, string app) { } diff --git a/test/Altinn.App.Core.Tests/Internal/App/AppMetadataTest.GetApplicationMetadata_deserialize_serialize_unmapped_properties.verified.txt b/test/Altinn.App.Core.Tests/Internal/App/AppMetadataTest.GetApplicationMetadata_deserialize_serialize_unmapped_properties.verified.txt index e847f790dc..9ebfdf0b25 100644 --- a/test/Altinn.App.Core.Tests/Internal/App/AppMetadataTest.GetApplicationMetadata_deserialize_serialize_unmapped_properties.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/App/AppMetadataTest.GetApplicationMetadata_deserialize_serialize_unmapped_properties.verified.txt @@ -3,7 +3,8 @@ "Features": { "footer": true, "processActions": true, - "jsonObjectInDataResponse": false + "jsonObjectInDataResponse": false, + "addInstanceIdentifierToLayoutRequests": false }, "OnEntry": { "InstanceSelection": null, diff --git a/test/Altinn.App.Core.Tests/Internal/App/FrontendFeaturesTest.cs b/test/Altinn.App.Core.Tests/Internal/App/FrontendFeaturesTest.cs index b5f6f5f747..95c27b0816 100644 --- a/test/Altinn.App.Core.Tests/Internal/App/FrontendFeaturesTest.cs +++ b/test/Altinn.App.Core.Tests/Internal/App/FrontendFeaturesTest.cs @@ -17,6 +17,7 @@ public async Task GetFeatures_returns_list_of_enabled_features() { "footer", true }, { "processActions", true }, { "jsonObjectInDataResponse", false }, + { "addInstanceIdentifierToLayoutRequests", false }, }; var featureManagerMock = new Mock(); IFrontendFeatures frontendFeatures = new FrontendFeatures(featureManagerMock.Object); @@ -34,9 +35,13 @@ public async Task GetFeatures_returns_list_of_enabled_features_when_feature_flag { "footer", true }, { "processActions", true }, { "jsonObjectInDataResponse", true }, + { "addInstanceIdentifierToLayoutRequests", true }, }; var featureManagerMock = new Mock(); featureManagerMock.Setup(f => f.IsEnabledAsync(FeatureFlags.JsonObjectInDataResponse)).ReturnsAsync(true); + featureManagerMock + .Setup(f => f.IsEnabledAsync(FeatureFlags.AddInstanceIdentifierToLayoutRequests)) + .ReturnsAsync(true); IFrontendFeatures frontendFeatures = new FrontendFeatures(featureManagerMock.Object); var actual = await frontendFeatures.GetFrontendFeatures(); actual.Should().BeEquivalentTo(expected); diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 5f1606be13..d061c669e7 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -1147,6 +1147,7 @@ namespace Altinn.App.Core.Features } public static class FeatureFlags { + public const string AddInstanceIdentifierToLayoutRequests = "AddInstanceIdentifierToLayoutRequests"; public const string JsonObjectInDataResponse = "JsonObjectInDataResponse"; } public interface IAppOptionsProvider @@ -2894,6 +2895,10 @@ namespace Altinn.App.Core.Internal.App { System.Threading.Tasks.Task GetApplication(string org, string app); } + public interface ICustomLayoutForInstance + { + System.Threading.Tasks.Task GetCustomLayoutForInstance(string layoutSetId, int instanceOwnerPartyId, System.Guid instanceGuid); + } public interface IFrontendFeatures { System.Threading.Tasks.Task> GetFrontendFeatures(); From 733ec02847f4c11f7d79ee7c8c119335a79ea3a8 Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Mon, 10 Nov 2025 12:35:24 +0100 Subject: [PATCH 06/12] add spec verification file --- ...esTests.Metadata_Custom_0_Metadata.verified.txt | 14 ++++++++++++++ ...Tests.Metadata_Standard_0_Metadata.verified.txt | 9 +++++++++ ...OnlyAppTests.ApplicationMetadata_0.verified.txt | 1 + 3 files changed, 24 insertions(+) diff --git a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt index d4ba4f3935..e08e24a537 100644 --- a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt +++ b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt @@ -407,6 +407,20 @@ requiredScopesServiceOwners: null } }, + { + endpoint: GET {org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}, + metadata: { + errorMessageTextResourceKeyUser: authorization.scopes.insufficient, + errorMessageTextResourceKeyServiceOwner: authorization.scopes.insufficient, + requiredScopesUsers: [ + custom:instances.read, + altinn:portal/enduser + ], + requiredScopesServiceOwners: [ + custom:serviceowner/instances.read + ] + } + }, { endpoint: GET {org}/{app}/instances/{instanceOwnerId:int}/{instanceId:guid}/data/{dataGuid:guid}/validate, metadata: { diff --git a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt index 189d2581d4..976342896e 100644 --- a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt +++ b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt @@ -382,6 +382,15 @@ requiredScopesServiceOwners: null } }, + { + endpoint: GET {org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}, + metadata: { + errorMessageTextResourceKeyUser: null, + errorMessageTextResourceKeyServiceOwner: null, + requiredScopesUsers: null, + requiredScopesServiceOwners: null + } + }, { endpoint: GET {org}/{app}/instances/{instanceOwnerId:int}/{instanceId:guid}/data/{dataGuid:guid}/validate, metadata: { diff --git a/test/Altinn.App.Integration.Tests/PartyTypesAllowed/_snapshots/SubunitOnlyAppTests.ApplicationMetadata_0.verified.txt b/test/Altinn.App.Integration.Tests/PartyTypesAllowed/_snapshots/SubunitOnlyAppTests.ApplicationMetadata_0.verified.txt index 490482384b..abf291d5c2 100644 --- a/test/Altinn.App.Integration.Tests/PartyTypesAllowed/_snapshots/SubunitOnlyAppTests.ApplicationMetadata_0.verified.txt +++ b/test/Altinn.App.Integration.Tests/PartyTypesAllowed/_snapshots/SubunitOnlyAppTests.ApplicationMetadata_0.verified.txt @@ -55,6 +55,7 @@ Response: { Id: ttd/basic, Features: { + addInstanceIdentifierToLayoutRequests: false, footer: true, jsonObjectInDataResponse: false, processActions: true From cf8a7aa4a6817fd766accf65c38aeb5530abbea2 Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Mon, 10 Nov 2025 13:59:12 +0100 Subject: [PATCH 07/12] update controller endpoint --- .../Controllers/ResourceController.cs | 4 +-- .../ResourceController_CustomLayoutTests.cs | 4 +-- ...ts.Metadata_Custom_0_Metadata.verified.txt | 28 +++++++++---------- ....Metadata_Standard_0_Metadata.verified.txt | 18 ++++++------ 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ResourceController.cs b/src/Altinn.App.Api/Controllers/ResourceController.cs index d5d197dc4d..0ccd75b13f 100644 --- a/src/Altinn.App.Api/Controllers/ResourceController.cs +++ b/src/Altinn.App.Api/Controllers/ResourceController.cs @@ -85,9 +85,9 @@ public ActionResult GetLayouts(string org, string app, string id) /// The instance id /// The layout set id /// A collection of FormLayout objects in JSON format. - [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/plain")] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "application/json")] [HttpGet] - [Route("{org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}")] + [Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}")] public async Task GetInstanceLayouts( string org, string app, diff --git a/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs b/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs index 208dc3b197..d0f6d09fdb 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs @@ -39,7 +39,7 @@ public async Task GetLayoutsForSet_WithCustomLayoutForInstanceService_ReturnsOk( TestData.PrepareInstance(org, app, instanceOwnerPartyId, instanceGuid); var response = await client.GetAsync( - $"/{org}/{app}/instance/{instanceOwnerPartyId}/{instanceGuid}/layouts/{layoutSetId}" + $"/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/layouts/{layoutSetId}" ); response.Should().HaveStatusCode(HttpStatusCode.OK); @@ -59,7 +59,7 @@ public async Task GetLayoutsForSet_WithoutCustomLayoutForInstanceService_Returns TestData.PrepareInstance(org, app, instanceOwnerPartyId, instanceGuid); var response = await client.GetAsync( - $"/{org}/{app}/instance/{instanceOwnerPartyId}/{instanceGuid}/layouts/{layoutSetId}" + $"/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/layouts/{layoutSetId}" ); response.Should().HaveStatusCode(HttpStatusCode.OK); diff --git a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt index e08e24a537..e3cde11ada 100644 --- a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt +++ b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt @@ -407,20 +407,6 @@ requiredScopesServiceOwners: null } }, - { - endpoint: GET {org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}, - metadata: { - errorMessageTextResourceKeyUser: authorization.scopes.insufficient, - errorMessageTextResourceKeyServiceOwner: authorization.scopes.insufficient, - requiredScopesUsers: [ - custom:instances.read, - altinn:portal/enduser - ], - requiredScopesServiceOwners: [ - custom:serviceowner/instances.read - ] - } - }, { endpoint: GET {org}/{app}/instances/{instanceOwnerId:int}/{instanceId:guid}/data/{dataGuid:guid}/validate, metadata: { @@ -729,6 +715,20 @@ ] } }, + { + endpoint: GET {org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}, + metadata: { + errorMessageTextResourceKeyUser: authorization.scopes.insufficient, + errorMessageTextResourceKeyServiceOwner: authorization.scopes.insufficient, + requiredScopesUsers: [ + custom:instances.read, + altinn:portal/enduser + ], + requiredScopesServiceOwners: [ + custom:serviceowner/instances.read + ] + } + }, { endpoint: GET {org}/{app}/legacy/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/copy, metadata: { diff --git a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt index 976342896e..0722034f3b 100644 --- a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt +++ b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt @@ -382,15 +382,6 @@ requiredScopesServiceOwners: null } }, - { - endpoint: GET {org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}, - metadata: { - errorMessageTextResourceKeyUser: null, - errorMessageTextResourceKeyServiceOwner: null, - requiredScopesUsers: null, - requiredScopesServiceOwners: null - } - }, { endpoint: GET {org}/{app}/instances/{instanceOwnerId:int}/{instanceId:guid}/data/{dataGuid:guid}/validate, metadata: { @@ -589,6 +580,15 @@ requiredScopesServiceOwners: null } }, + { + endpoint: GET {org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId}, + metadata: { + errorMessageTextResourceKeyUser: null, + errorMessageTextResourceKeyServiceOwner: null, + requiredScopesUsers: null, + requiredScopesServiceOwners: null + } + }, { endpoint: GET {org}/{app}/legacy/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/copy, metadata: { From ec066af83ce061debb75106f01d011df7a6369ff Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Mon, 10 Nov 2025 13:59:22 +0100 Subject: [PATCH 08/12] use xunit assertions --- .../ResourceController_CustomLayoutTests.cs | 19 +++++++++---------- ...ngeDetection.SaveJsonSwagger.verified.json | 4 ++-- ...ouldNotChange_Unintentionally.verified.txt | 6 +++--- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs b/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs index d0f6d09fdb..851ccf4d73 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Altinn.App.Api.Tests.Data; using Altinn.App.Core.Internal.App; -using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; @@ -42,9 +41,9 @@ public async Task GetLayoutsForSet_WithCustomLayoutForInstanceService_ReturnsOk( $"/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/layouts/{layoutSetId}" ); - response.Should().HaveStatusCode(HttpStatusCode.OK); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); - content.Should().Be(instanceGuid.ToString()); + Assert.Equal(instanceGuid.ToString(), content); } [Fact] @@ -62,15 +61,15 @@ public async Task GetLayoutsForSet_WithoutCustomLayoutForInstanceService_Returns $"/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/layouts/{layoutSetId}" ); - response.Should().HaveStatusCode(HttpStatusCode.OK); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); using var jsonDoc = JsonDocument.Parse(content); var root = jsonDoc.RootElement; - root.ValueKind.Should().Be(JsonValueKind.Object); - root.TryGetProperty("page", out var pageLayout).Should().BeTrue(); - pageLayout.TryGetProperty("data", out var data).Should().BeTrue(); - data.TryGetProperty("layout", out var layout).Should().BeTrue(); - layout.ValueKind.Should().Be(JsonValueKind.Array); - layout.GetArrayLength().Should().BeGreaterThan(0); + Assert.Equal(JsonValueKind.Object, root.ValueKind); + Assert.True(root.TryGetProperty("page", out var pageLayout)); + Assert.True(pageLayout.TryGetProperty("data", out var data)); + Assert.True(data.TryGetProperty("layout", out var layout)); + Assert.Equal(JsonValueKind.Array, layout.ValueKind); + Assert.True(layout.GetArrayLength() > 0); } } diff --git a/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json b/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json index 15e99670d9..63f5eabc62 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json +++ b/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json @@ -5176,7 +5176,7 @@ } } }, - "/{org}/{app}/instance/{instanceOwnerPartyId}/{instanceId}/layouts/{layoutSetId}": { + "/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceId}/layouts/{layoutSetId}": { "get": { "tags": [ "Resource" @@ -5234,7 +5234,7 @@ "200": { "description": "OK", "content": { - "text/plain": { + "application/json": { "schema": { "type": "string" } diff --git a/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 718f8b1c59..26e3419079 100644 --- a/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -522,9 +522,9 @@ namespace Altinn.App.Api.Controllers [Microsoft.AspNetCore.Mvc.Route("{org}/{app}/api/v1/footer")] public System.Threading.Tasks.Task GetFooterLayout(string org, string app) { } [Microsoft.AspNetCore.Mvc.HttpGet] - [Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(string), 200, "text/plain", new string[0])] - [Microsoft.AspNetCore.Mvc.Route("{org}/{app}/instance/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetId" + - "}")] + [Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(string), 200, "application/json", new string[0])] + [Microsoft.AspNetCore.Mvc.Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetI" + + "d}")] public System.Threading.Tasks.Task GetInstanceLayouts(string org, string app, int instanceOwnerPartyId, string instanceId, string layoutSetId) { } [Microsoft.AspNetCore.Mvc.HttpGet] [Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(string), 200, "application/json", new string[0])] From 0bfe1bab01d0316237de16c9ec4770598c1da157 Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Tue, 16 Dec 2025 08:57:29 +0100 Subject: [PATCH 09/12] feat: add layout settings endpoint to ICustomLayoutForInstance interface --- .../Controllers/ResourceController.cs | 35 ++++++++++ .../Internal/App/ICustomLayoutForInstance.cs | 8 +++ .../ResourceController_CustomLayoutTests.cs | 56 +++++++++++++++ ...ngeDetection.SaveJsonSwagger.verified.json | 68 +++++++++++++++++++ ...ouldNotChange_Unintentionally.verified.txt | 5 ++ ...ouldNotChange_Unintentionally.verified.txt | 1 + ...ts.Metadata_Custom_0_Metadata.verified.txt | 14 ++++ ....Metadata_Standard_0_Metadata.verified.txt | 9 +++ 8 files changed, 196 insertions(+) diff --git a/src/Altinn.App.Api/Controllers/ResourceController.cs b/src/Altinn.App.Api/Controllers/ResourceController.cs index 0ccd75b13f..06c2021cf7 100644 --- a/src/Altinn.App.Api/Controllers/ResourceController.cs +++ b/src/Altinn.App.Api/Controllers/ResourceController.cs @@ -125,6 +125,41 @@ public ActionResult GetLayoutSettings(string org, string app) return Ok(settings); } + /// + /// Endpoint for layout settings with instance context. + /// Uses ICustomLayoutForInstance if implemented with IAppResources as fallback. + /// + /// The application owner short name + /// The application name + /// The instance owner party id + /// The instance id + /// The layout set id + /// The settings in the form of a string. + [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "application/json")] + [HttpGet] + [Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layoutsettings/{layoutSetId}")] + public async Task GetInstanceLayoutSettings( + string org, + string app, + int instanceOwnerPartyId, + string instanceId, + string layoutSetId + ) + { + ICustomLayoutForInstance? customLayoutService = _appImplementationFactory.Get(); + if (customLayoutService is not null) + { + string? customLayoutSettings = await customLayoutService.GetCustomLayoutSettingsForInstance( + layoutSetId, + instanceOwnerPartyId, + Guid.Parse(instanceId) + ); + return Ok(customLayoutSettings); + } + string? settings = _appResourceService.GetLayoutSettingsStringForSet(layoutSetId); + return Ok(settings); + } + /// /// Get the layout settings. /// diff --git a/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs b/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs index 2eee82594d..0c1745b098 100644 --- a/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs +++ b/src/Altinn.App.Core/Internal/App/ICustomLayoutForInstance.cs @@ -15,4 +15,12 @@ public interface ICustomLayoutForInstance /// The instance owner party ID /// The instance GUID Task GetCustomLayoutForInstance(string layoutSetId, int instanceOwnerPartyId, Guid instanceGuid); + + /// + /// Gets the custom layout settings + /// + /// The layout set ID + /// The instance owner party ID + /// The instance GUID + Task GetCustomLayoutSettingsForInstance(string layoutSetId, int instanceOwnerPartyId, Guid instanceGuid); } diff --git a/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs b/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs index 851ccf4d73..a0c6fc37f7 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ResourceController_CustomLayoutTests.cs @@ -19,6 +19,15 @@ private class CustomLayoutForInstance : ICustomLayoutForInstance { return Task.FromResult(instanceId.ToString()); } + + public Task GetCustomLayoutSettingsForInstance( + string layoutSetId, + int instanceOwnerPartyId, + Guid instanceId + ) + { + return Task.FromResult(instanceId.ToString()); + } } [Fact] @@ -72,4 +81,51 @@ public async Task GetLayoutsForSet_WithoutCustomLayoutForInstanceService_Returns Assert.Equal(JsonValueKind.Array, layout.ValueKind); Assert.True(layout.GetArrayLength() > 0); } + + [Fact] + public async Task GetLayoutSettingsForSet_WithCustomLayoutForInstanceService_ReturnsOk() + { + OverrideServicesForThisTest = (services) => + { + services.AddSingleton(); + }; + + string org = "tdd"; + string app = "contributer-restriction"; + int instanceOwnerPartyId = 500600; + Guid instanceGuid = Guid.Parse("cff1cb24-5bc1-4888-8e06-c634753c5144"); + string layoutSetId = "default"; + using HttpClient client = GetRootedUserClient(org, app, 1337, instanceOwnerPartyId); + + TestData.PrepareInstance(org, app, instanceOwnerPartyId, instanceGuid); + var response = await client.GetAsync( + $"/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/layoutsettings/{layoutSetId}" + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(instanceGuid.ToString(), content); + } + + [Fact] + public async Task GetLayoutSettingsForSet_WithoutCustomLayoutForInstanceService_ReturnsOk() + { + string org = "tdd"; + string app = "contributer-restriction"; + int instanceOwnerPartyId = 500600; + Guid instanceGuid = Guid.Parse("cff1cb24-5bc1-4888-8e06-c634753c5144"); + string layoutSetId = "default"; + using HttpClient client = GetRootedUserClient(org, app, 1337, instanceOwnerPartyId); + + TestData.PrepareInstance(org, app, instanceOwnerPartyId, instanceGuid); + var response = await client.GetAsync( + $"/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/layoutsettings/{layoutSetId}" + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + using var jsonDoc = JsonDocument.Parse(content); + var root = jsonDoc.RootElement; + Assert.Equal(JsonValueKind.Object, root.ValueKind); + } } diff --git a/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json b/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json index 29f734182a..e3b81f7604 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json +++ b/test/Altinn.App.Api.Tests/OpenApi/OpenApiSpecChangeDetection.SaveJsonSwagger.verified.json @@ -5284,6 +5284,74 @@ } } }, + "/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceId}/layoutsettings/{layoutSetId}": { + "get": { + "tags": [ + "Resource" + ], + "summary": "Endpoint for layout settings with instance context.\nUses ICustomLayoutForInstance if implemented with IAppResources as fallback.", + "parameters": [ + { + "name": "org", + "in": "path", + "description": "The application owner short name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app", + "in": "path", + "description": "The application name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "instanceOwnerPartyId", + "in": "path", + "description": "The instance owner party id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "instanceId", + "in": "path", + "description": "The instance id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "layoutSetId", + "in": "path", + "description": "The layout set id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/{org}/{app}/api/layoutsettings/{id}": { "get": { "tags": [ diff --git a/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 26e3419079..e42b7e9375 100644 --- a/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Api.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -523,6 +523,11 @@ namespace Altinn.App.Api.Controllers public System.Threading.Tasks.Task GetFooterLayout(string org, string app) { } [Microsoft.AspNetCore.Mvc.HttpGet] [Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(string), 200, "application/json", new string[0])] + [Microsoft.AspNetCore.Mvc.Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layoutsettings/{lay" + + "outSetId}")] + public System.Threading.Tasks.Task GetInstanceLayoutSettings(string org, string app, int instanceOwnerPartyId, string instanceId, string layoutSetId) { } + [Microsoft.AspNetCore.Mvc.HttpGet] + [Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(string), 200, "application/json", new string[0])] [Microsoft.AspNetCore.Mvc.Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layouts/{layoutSetI" + "d}")] public System.Threading.Tasks.Task GetInstanceLayouts(string org, string app, int instanceOwnerPartyId, string instanceId, string layoutSetId) { } diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 58274bff5c..c58f143a51 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -2937,6 +2937,7 @@ namespace Altinn.App.Core.Internal.App public interface ICustomLayoutForInstance { System.Threading.Tasks.Task GetCustomLayoutForInstance(string layoutSetId, int instanceOwnerPartyId, System.Guid instanceGuid); + System.Threading.Tasks.Task GetCustomLayoutSettingsForInstance(string layoutSetId, int instanceOwnerPartyId, System.Guid instanceGuid); } public interface IFrontendFeatures { diff --git a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt index e3cde11ada..3f8776c66a 100644 --- a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt +++ b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Custom_0_Metadata.verified.txt @@ -729,6 +729,20 @@ ] } }, + { + endpoint: GET {org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layoutsettings/{layoutSetId}, + metadata: { + errorMessageTextResourceKeyUser: authorization.scopes.insufficient, + errorMessageTextResourceKeyServiceOwner: authorization.scopes.insufficient, + requiredScopesUsers: [ + custom:instances.read, + altinn:portal/enduser + ], + requiredScopesServiceOwners: [ + custom:serviceowner/instances.read + ] + } + }, { endpoint: GET {org}/{app}/legacy/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/copy, metadata: { diff --git a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt index 0722034f3b..c8e6bb1e82 100644 --- a/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt +++ b/test/Altinn.App.Integration.Tests/CustomScopes/_snapshots/CustomScopesTests.Metadata_Standard_0_Metadata.verified.txt @@ -589,6 +589,15 @@ requiredScopesServiceOwners: null } }, + { + endpoint: GET {org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceId}/layoutsettings/{layoutSetId}, + metadata: { + errorMessageTextResourceKeyUser: null, + errorMessageTextResourceKeyServiceOwner: null, + requiredScopesUsers: null, + requiredScopesServiceOwners: null + } + }, { endpoint: GET {org}/{app}/legacy/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/copy, metadata: { From 33b9d3f7051340b3dc955f4d245ab897ab1d3cef Mon Sep 17 00:00:00 2001 From: Jonas Dyrlie Date: Wed, 28 Jan 2026 13:38:37 +0100 Subject: [PATCH 10/12] feat: fallback to regular layoutset/layoutsettings if custom interface returns `null` --- src/Altinn.App.Api/Controllers/ResourceController.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ResourceController.cs b/src/Altinn.App.Api/Controllers/ResourceController.cs index 06c2021cf7..c7e0d18320 100644 --- a/src/Altinn.App.Api/Controllers/ResourceController.cs +++ b/src/Altinn.App.Api/Controllers/ResourceController.cs @@ -104,7 +104,10 @@ string layoutSetId instanceOwnerPartyId, Guid.Parse(instanceId) ); - return Ok(customLayout); + if (customLayout is not null) + { + return Ok(customLayout); + } } string layouts = _appResourceService.GetLayoutsForSet(layoutSetId); return Ok(layouts); @@ -154,7 +157,10 @@ string layoutSetId instanceOwnerPartyId, Guid.Parse(instanceId) ); - return Ok(customLayoutSettings); + if (customLayoutSettings is not null) + { + return Ok(customLayoutSettings); + } } string? settings = _appResourceService.GetLayoutSettingsStringForSet(layoutSetId); return Ok(settings); From bffb1113af8ca68ffd661800924bd25bbaa5d3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 29 Jan 2026 11:17:48 +0100 Subject: [PATCH 11/12] Fix PdfService file name logic. Make null handling in DataElementIdentifier safer. --- .../Internal/Pdf/PdfService.cs | 4 +- .../Models/DataElementIdentifier.cs | 5 +- .../Models/DataElementIdentifierTests.cs | 124 ++++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs diff --git a/src/Altinn.App.Core/Internal/Pdf/PdfService.cs b/src/Altinn.App.Core/Internal/Pdf/PdfService.cs index 8d628d5726..737d9c3673 100644 --- a/src/Altinn.App.Core/Internal/Pdf/PdfService.cs +++ b/src/Altinn.App.Core/Internal/Pdf/PdfService.cs @@ -408,7 +408,9 @@ private static List> CreateAutoPdfTaskIdsQueryParam ?? throw new InvalidOperationException("LayoutEvaluatorState should not be null. No current task?"); DataElementIdentifier? dataElementIdentifier = - subformDataElementId != null ? new DataElementIdentifier(subformDataElementId) : default; + subformDataElementId != null + ? new DataElementIdentifier(subformDataElementId) + : (DataElementIdentifier?)null; var componentContext = new ComponentContext( state, diff --git a/src/Altinn.App.Core/Models/DataElementIdentifier.cs b/src/Altinn.App.Core/Models/DataElementIdentifier.cs index 095c345770..194a7efbd5 100644 --- a/src/Altinn.App.Core/Models/DataElementIdentifier.cs +++ b/src/Altinn.App.Core/Models/DataElementIdentifier.cs @@ -55,14 +55,15 @@ public DataElementIdentifier(DataElement dataElement) /// /// Implicit conversion to allow DataElements to be used as DataElementIds /// - public static implicit operator DataElementIdentifier(DataElement dataElement) => new(dataElement); + public static implicit operator DataElementIdentifier(DataElement dataElement) => + dataElement is null ? throw new ArgumentNullException(nameof(dataElement)) : new(dataElement); /// /// Implicit conversion to allow DataElements to be used as DataElementIds, /// but accept and return null values /// public static implicit operator DataElementIdentifier?(DataElement? dataElement) => - dataElement is null ? default : new(dataElement); + dataElement is null ? null : new(dataElement); /// /// Make the ToString method return the ID diff --git a/test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs b/test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs new file mode 100644 index 0000000000..f15e5068af --- /dev/null +++ b/test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs @@ -0,0 +1,124 @@ +using Altinn.App.Core.Models; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.PlatformServices.Tests.Models; + +public class DataElementIdentifierTests +{ + [Fact] + public void NullableDataElementIdentifier_Default_ShouldBeNull() + { + DataElementIdentifier? identifier = default; + + Assert.True(identifier is null); + Assert.False(identifier.HasValue); + } + + [Fact] + public void NullableDataElementIdentifier_Null_ShouldBeNull() + { + DataElementIdentifier? identifier = null; + + Assert.True(identifier is null); + Assert.False(identifier.HasValue); + } + + [Fact] + public void NullableDataElementIdentifier_FromNullDataElement_ShouldBeNull() + { + DataElement? dataElement = null; + DataElementIdentifier? identifier = dataElement; + + Assert.True(identifier is null); + Assert.False(identifier.HasValue); + } + + [Fact] + public void NullableDataElementIdentifier_FromDataElement_ShouldHaveValue() + { + var guid = Guid.NewGuid(); + DataElement dataElement = new() { Id = guid.ToString(), DataType = "Model" }; + DataElementIdentifier? identifier = dataElement; + + Assert.True(identifier.HasValue); + Assert.Equal(guid, identifier.Value.Guid); + Assert.Equal(guid.ToString(), identifier.Value.Id); + Assert.Equal("Model", identifier.Value.DataTypeId); + } + + [Fact] + public void DataElementIdentifier_FromNullDataElement_ShouldThrowArgumentNullException() + { + DataElement? dataElement = null; + + Assert.Throws(() => + { + DataElementIdentifier identifier = dataElement!; + }); + } + + [Fact] + public void DataElementIdentifier_FromDataElement_ShouldWork() + { + var guid = Guid.NewGuid(); + DataElement dataElement = new() { Id = guid.ToString(), DataType = "Model" }; + DataElementIdentifier identifier = dataElement; + + Assert.Equal(guid, identifier.Guid); + Assert.Equal(guid.ToString(), identifier.Id); + Assert.Equal("Model", identifier.DataTypeId); + } + + [Fact] + public void DataElementIdentifier_FromString_ShouldWork() + { + var guid = Guid.NewGuid(); + var identifier = new DataElementIdentifier(guid.ToString()); + + Assert.Equal(guid, identifier.Guid); + Assert.Equal(guid.ToString(), identifier.Id); + Assert.Null(identifier.DataTypeId); + } + + [Fact] + public void DataElementIdentifier_FromGuid_ShouldWork() + { + var guid = Guid.NewGuid(); + var identifier = new DataElementIdentifier(guid); + + Assert.Equal(guid, identifier.Guid); + Assert.Equal(guid.ToString(), identifier.Id); + Assert.Null(identifier.DataTypeId); + } + + [Fact] + public void DataElementIdentifier_Equality_ShouldCompareByGuid() + { + var guid = Guid.NewGuid(); + var identifier1 = new DataElementIdentifier(guid); + var identifier2 = new DataElementIdentifier(guid.ToString()); + + Assert.True(identifier1 == identifier2); + Assert.True(identifier1.Equals(identifier2)); + Assert.Equal(identifier1.GetHashCode(), identifier2.GetHashCode()); + } + + [Fact] + public void DataElementIdentifier_Inequality_ShouldCompareByGuid() + { + var identifier1 = new DataElementIdentifier(Guid.NewGuid()); + var identifier2 = new DataElementIdentifier(Guid.NewGuid()); + + Assert.True(identifier1 != identifier2); + Assert.False(identifier1.Equals(identifier2)); + } + + [Fact] + public void DataElementIdentifier_ToString_ShouldReturnId() + { + var guid = Guid.NewGuid(); + var identifier = new DataElementIdentifier(guid); + + Assert.Equal(guid.ToString(), identifier.ToString()); + } +} From 171de8c6bf26e85b11b4a41c5b4177ac02729bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 29 Jan 2026 11:30:22 +0100 Subject: [PATCH 12/12] change namespace in test (cherry picked from commit cea36ab114854234b887d3c727a5e34bf1e0314f) --- test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs b/test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs index f15e5068af..ff66ec64a5 100644 --- a/test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs +++ b/test/Altinn.App.Core.Tests/Models/DataElementIdentifierTests.cs @@ -1,7 +1,7 @@ using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; -namespace Altinn.App.PlatformServices.Tests.Models; +namespace Altinn.App.Core.Tests.Models; public class DataElementIdentifierTests {