Skip to content

Commit 5fef685

Browse files
authored
Merge branch 'main' into renovate/main-nuget-minor-patch
2 parents 5b72c07 + ba02911 commit 5fef685

File tree

15 files changed

+795
-47
lines changed

15 files changed

+795
-47
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Microsoft.AspNetCore.Mvc.Filters;
2+
3+
namespace Altinn.App.Api.Controllers.Attributes;
4+
5+
[AttributeUsage(AttributeTargets.Class)]
6+
internal class JsonSettingsNameAttribute : Attribute, IFilterMetadata
7+
{
8+
internal JsonSettingsNameAttribute(string name)
9+
{
10+
Name = name;
11+
}
12+
13+
internal string Name { get; }
14+
}
15+
16+
internal static class JsonSettingNames
17+
{
18+
internal const string AltinnApi = "AltinnApi";
19+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Text.Encodings.Web;
2+
using System.Text.Json;
3+
using Altinn.App.Api.Extensions;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.AspNetCore.Mvc.Formatters;
6+
7+
namespace Altinn.App.Api.Controllers.Conventions;
8+
9+
internal sealed class AltinnApiJsonFormatter : SystemTextJsonOutputFormatter
10+
{
11+
private AltinnApiJsonFormatter(string settingsName, JsonSerializerOptions options)
12+
: base(options)
13+
{
14+
SettingsName = settingsName;
15+
}
16+
17+
internal string SettingsName { get; }
18+
19+
public override bool CanWriteResult(OutputFormatterCanWriteContext context)
20+
{
21+
if (context.HttpContext.GetJsonSettingsName() != SettingsName)
22+
{
23+
return false;
24+
}
25+
26+
return base.CanWriteResult(context);
27+
}
28+
29+
internal static AltinnApiJsonFormatter CreateFormatter(string settingsName, JsonOptions jsonOptions)
30+
{
31+
var jsonSerializerOptions = jsonOptions.JsonSerializerOptions;
32+
33+
if (jsonSerializerOptions.Encoder is null)
34+
{
35+
// If the user hasn't explicitly configured the encoder, use the less strict encoder that does not encode all non-ASCII characters.
36+
jsonSerializerOptions = new JsonSerializerOptions(jsonSerializerOptions)
37+
{
38+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
39+
};
40+
}
41+
42+
return new AltinnApiJsonFormatter(settingsName, jsonSerializerOptions);
43+
}
44+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Altinn.App.Api.Controllers.Attributes;
2+
using Microsoft.AspNetCore.Mvc.ApplicationModels;
3+
4+
namespace Altinn.App.Api.Controllers.Conventions;
5+
6+
internal class AltinnControllerConventions : IControllerModelConvention
7+
{
8+
public void Apply(ControllerModel controller)
9+
{
10+
controller.Filters.Add(new JsonSettingsNameAttribute(JsonSettingNames.AltinnApi));
11+
}
12+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.AspNetCore.Mvc.Formatters;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace Altinn.App.Api.Controllers.Conventions;
6+
7+
/// <summary>
8+
/// Configures MVC options to use a specific JSON serialization settings for enum-to-number conversion.
9+
/// </summary>
10+
public class ConfigureMvcJsonOptions : IConfigureOptions<MvcOptions>
11+
{
12+
private readonly string _jsonSettingsName;
13+
private readonly IOptionsMonitor<JsonOptions> _jsonOptions;
14+
15+
/// <summary>
16+
/// Initializes a new instance of the <see cref="ConfigureMvcJsonOptions"/> class.
17+
/// </summary>
18+
/// <param name="jsonSettingsName">The name of the JSON settings to be used for enum-to-number conversion.</param>
19+
/// /// <param name="jsonOptions">An <see cref="IOptionsMonitor{TOptions}"/> to access the named JSON options.</param>
20+
public ConfigureMvcJsonOptions(string jsonSettingsName, IOptionsMonitor<JsonOptions> jsonOptions)
21+
{
22+
_jsonSettingsName = jsonSettingsName;
23+
_jsonOptions = jsonOptions;
24+
}
25+
26+
/// <summary>
27+
/// Configures the MVC options to use the <see cref="AltinnApiJsonFormatter"/> for the specified JSON settings.
28+
/// Makes sure to add to the formatter before the default <see cref="SystemTextJsonOutputFormatter"/> .
29+
/// </summary>
30+
/// <param name="options">The <see cref="MvcOptions"/> to configure.</param>
31+
public void Configure(MvcOptions options)
32+
{
33+
var defaultJsonFormatter =
34+
options.OutputFormatters.OfType<SystemTextJsonOutputFormatter>().FirstOrDefault()
35+
?? throw new InvalidOperationException("Could not find the default JSON output formatter");
36+
37+
var indexOfDefaultJsonFormatter = options.OutputFormatters.IndexOf(defaultJsonFormatter);
38+
39+
var jsonOptions = _jsonOptions.Get(_jsonSettingsName);
40+
options.OutputFormatters.Insert(
41+
indexOfDefaultJsonFormatter,
42+
AltinnApiJsonFormatter.CreateFormatter(_jsonSettingsName, jsonOptions)
43+
);
44+
}
45+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Altinn.App.Api.Controllers.Attributes;
2+
3+
namespace Altinn.App.Api.Extensions;
4+
5+
internal static class HttpContextExtensions
6+
{
7+
internal static string? GetJsonSettingsName(this HttpContext context)
8+
{
9+
return context.GetEndpoint()?.Metadata.GetMetadata<JsonSettingsNameAttribute>()?.Name;
10+
}
11+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Altinn.App.Api.Controllers.Conventions;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace Altinn.App.Api.Extensions;
6+
7+
internal static class MvcBuilderExtensions
8+
{
9+
internal static IMvcBuilder AddJsonOptions(
10+
this IMvcBuilder builder,
11+
string settingsName,
12+
Action<JsonOptions> configure
13+
)
14+
{
15+
ArgumentNullException.ThrowIfNull(builder);
16+
ArgumentNullException.ThrowIfNull(configure);
17+
18+
builder.Services.Configure(settingsName, configure);
19+
20+
builder.Services.AddSingleton<IConfigureOptions<MvcOptions>>(sp =>
21+
{
22+
var options = sp.GetRequiredService<IOptionsMonitor<JsonOptions>>();
23+
return new ConfigureMvcJsonOptions(settingsName, options);
24+
});
25+
26+
return builder;
27+
}
28+
}

src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.Diagnostics;
22
using Altinn.App.Api.Controllers;
3+
using Altinn.App.Api.Controllers.Attributes;
4+
using Altinn.App.Api.Controllers.Conventions;
35
using Altinn.App.Api.Helpers;
46
using Altinn.App.Api.Infrastructure.Filters;
57
using Altinn.App.Api.Infrastructure.Health;
@@ -43,10 +45,18 @@ public static void AddAltinnAppControllersWithViews(this IServiceCollection serv
4345
IMvcBuilder mvcBuilder = services.AddControllersWithViews(options =>
4446
{
4547
options.Filters.Add<TelemetryEnrichingResultFilter>();
48+
options.Conventions.Add(new AltinnControllerConventions());
4649
});
4750
mvcBuilder
4851
.AddApplicationPart(typeof(InstancesController).Assembly)
4952
.AddXmlSerializerFormatters()
53+
.AddJsonOptions(
54+
JsonSettingNames.AltinnApi,
55+
options =>
56+
{
57+
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
58+
}
59+
)
5060
.AddJsonOptions(options =>
5161
{
5262
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;

src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs

Lines changed: 31 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Globalization;
2+
using System.Text.Json;
23
using System.Text.RegularExpressions;
34
using Altinn.App.Core.Models.Expressions;
45
using Altinn.App.Core.Models.Layout.Components;
@@ -473,54 +474,39 @@ bool ab
473474
return a >= b; // Actual implementation
474475
}
475476

476-
private static string? ToStringForEquals(object? value)
477-
{
478-
if (value is null)
479-
{
480-
return null;
481-
}
482-
483-
if (value is bool bvalue)
484-
{
485-
return bvalue ? "true" : "false";
486-
}
487-
488-
if (value is string svalue)
477+
internal static string? ToStringForEquals(object? value) =>
478+
value switch
489479
{
480+
null => null,
481+
bool bValue => bValue ? "true" : "false",
490482
// Special case for "TruE" to be equal to true
491-
if ("true".Equals(svalue, StringComparison.OrdinalIgnoreCase))
492-
{
493-
return "true";
494-
}
495-
else if ("false".Equals(svalue, StringComparison.OrdinalIgnoreCase))
496-
{
497-
return "false";
498-
}
499-
else if ("null".Equals(svalue, StringComparison.OrdinalIgnoreCase))
500-
{
501-
return null;
502-
}
503-
504-
return svalue;
505-
}
506-
else if (value is decimal decvalue)
507-
{
508-
return decvalue.ToString(CultureInfo.InvariantCulture);
509-
}
510-
else if (value is double doubvalue)
511-
{
512-
return doubvalue.ToString(CultureInfo.InvariantCulture);
513-
}
514-
else if (value is int intvalue)
515-
{
516-
return intvalue.ToString(CultureInfo.InvariantCulture);
517-
}
518-
519-
//TODO: consider accepting more types that might be used in model (eg Datetime)
520-
throw new NotImplementedException();
521-
}
483+
string sValue when "true".Equals(sValue, StringComparison.OrdinalIgnoreCase) => "true",
484+
string sValue when "false".Equals(sValue, StringComparison.OrdinalIgnoreCase) => "false",
485+
string sValue when "null".Equals(sValue, StringComparison.OrdinalIgnoreCase) => null,
486+
string sValue => sValue,
487+
decimal decValue => decValue.ToString(CultureInfo.InvariantCulture),
488+
double doubleValue => doubleValue.ToString(CultureInfo.InvariantCulture),
489+
float floatValue => floatValue.ToString(CultureInfo.InvariantCulture),
490+
int intValue => intValue.ToString(CultureInfo.InvariantCulture),
491+
uint uintValue => uintValue.ToString(CultureInfo.InvariantCulture),
492+
short shortValue => shortValue.ToString(CultureInfo.InvariantCulture),
493+
ushort ushortValue => ushortValue.ToString(CultureInfo.InvariantCulture),
494+
long longValue => longValue.ToString(CultureInfo.InvariantCulture),
495+
ulong ulongValue => ulongValue.ToString(CultureInfo.InvariantCulture),
496+
byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture),
497+
sbyte sbyteValue => sbyteValue.ToString(CultureInfo.InvariantCulture),
498+
// BigInteger bigIntValue => bigIntValue.ToString(CultureInfo.InvariantCulture), // Big integer not supported in json
499+
DateTime dtValue => JsonSerializer.Serialize(dtValue),
500+
DateOnly dateValue => JsonSerializer.Serialize(dateValue),
501+
TimeOnly timeValue => JsonSerializer.Serialize(timeValue),
502+
//TODO: Consider having JsonSerializer as a fallback for everything (including arrays and objects)
503+
_
504+
=> throw new NotImplementedException(
505+
$"ToStringForEquals not implemented for type {value.GetType().Name}"
506+
)
507+
};
522508

523-
private static bool? EqualsImplementation(object?[] args)
509+
internal static bool? EqualsImplementation(object?[] args)
524510
{
525511
if (args.Length != 2)
526512
{
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Text.Encodings.Web;
2+
using System.Text.Json.Serialization.Metadata;
3+
using Altinn.App.Api.Controllers.Attributes;
4+
using Altinn.App.Api.Controllers.Conventions;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.AspNetCore.Mvc.Formatters;
8+
9+
namespace Altinn.App.Api.Tests.Controllers.Conventions;
10+
11+
public class AltinnApiJsonFormatterTests
12+
{
13+
[Fact]
14+
public void CreateFormatter_WhenEncoderIsNotNull_PreservesEncoder()
15+
{
16+
// Arrange
17+
string settingsName = JsonSettingNames.AltinnApi;
18+
var originalEncoder = JavaScriptEncoder.Default;
19+
20+
var jsonOptions = new JsonOptions();
21+
jsonOptions.JsonSerializerOptions.Encoder = originalEncoder;
22+
jsonOptions.JsonSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver();
23+
24+
// Act
25+
var formatter = AltinnApiJsonFormatter.CreateFormatter(settingsName, jsonOptions);
26+
27+
// Assert
28+
Assert.NotNull(formatter);
29+
Assert.Equal(settingsName, formatter.SettingsName);
30+
Assert.Equal(originalEncoder, formatter.SerializerOptions.Encoder);
31+
}
32+
33+
[Fact]
34+
public void CanWriteResult_SettingsNameMatches_ReturnsTrue()
35+
{
36+
// Arrange
37+
string settingsName = JsonSettingNames.AltinnApi;
38+
39+
var jsonOptions = new JsonOptions();
40+
jsonOptions.JsonSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver();
41+
42+
var formatter = AltinnApiJsonFormatter.CreateFormatter(settingsName, jsonOptions);
43+
44+
var httpContext = new DefaultHttpContext();
45+
46+
// Create an Endpoint with JsonSettingsNameAttribute
47+
var endpoint = new Endpoint(
48+
requestDelegate: null,
49+
metadata: new EndpointMetadataCollection(new JsonSettingsNameAttribute(settingsName)),
50+
displayName: null
51+
);
52+
53+
httpContext.SetEndpoint(endpoint);
54+
55+
var context = new OutputFormatterWriteContext(
56+
httpContext,
57+
(stream, encoding) => new StreamWriter(stream, encoding),
58+
typeof(object),
59+
new object()
60+
);
61+
62+
// Act
63+
bool canWrite = formatter.CanWriteResult(context);
64+
65+
// Assert
66+
Assert.True(canWrite);
67+
}
68+
69+
[Fact]
70+
public void CanWriteResult_SettingsNameMisMatch_ReturnsFalse()
71+
{
72+
// Arrange
73+
string formatterSettingsName = "FormatterSettingName";
74+
string endpointSettingsName = "EndpointSettingName";
75+
76+
var jsonOptions = new JsonOptions();
77+
jsonOptions.JsonSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver();
78+
79+
var formatter = AltinnApiJsonFormatter.CreateFormatter(formatterSettingsName, jsonOptions);
80+
81+
var httpContext = new DefaultHttpContext();
82+
83+
// Create an Endpoint with JsonSettingsNameAttribute with a different name
84+
var endpoint = new Endpoint(
85+
requestDelegate: null,
86+
metadata: new EndpointMetadataCollection(new JsonSettingsNameAttribute(endpointSettingsName)),
87+
displayName: null
88+
);
89+
90+
httpContext.SetEndpoint(endpoint);
91+
92+
var context = new OutputFormatterWriteContext(
93+
httpContext,
94+
(stream, encoding) => new StreamWriter(stream, encoding),
95+
typeof(object),
96+
new object()
97+
);
98+
99+
// Act
100+
bool canWrite = formatter.CanWriteResult(context);
101+
102+
// Assert
103+
Assert.False(canWrite);
104+
}
105+
}

0 commit comments

Comments
 (0)