diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0210b890d..1f3a6c753 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,7 @@ jobs: Deno1303, DotNet60, DotNet80, + DotNet90, FlutterStable, FlutterBeta, Go112, diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index ff9432a3b..d4c13dff4 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -380,6 +380,11 @@ public function getFiles(): array 'destination' => '{{ spec.title | caseUcfirst }}/Converters/ValueClassConverter.cs', 'template' => 'dotnet/Package/Converters/ValueClassConverter.cs.twig', ], + [ + 'scope' => 'default', + 'destination' => '{{ spec.title | caseUcfirst }}/Converters/ObjectToInferredTypesConverter.cs', + 'template' => 'dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.title | caseUcfirst }}/Extensions/Extensions.cs', diff --git a/templates/dotnet/Package/Client.cs.twig b/templates/dotnet/Package/Client.cs.twig index c3ac1ae1e..8f9527790 100644 --- a/templates/dotnet/Package/Client.cs.twig +++ b/templates/dotnet/Package/Client.cs.twig @@ -1,7 +1,3 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; using System; using System.Collections.Generic; using System.IO; @@ -9,6 +5,8 @@ using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using {{ spec.title | caseUcfirst }}.Converters; using {{ spec.title | caseUcfirst }}.Extensions; @@ -29,26 +27,28 @@ namespace {{ spec.title | caseUcfirst }} private static readonly int ChunkSize = 5 * 1024 * 1024; - public static JsonSerializerSettings DeserializerSettings { get; set; } = new JsonSerializerSettings + public static JsonSerializerOptions DeserializerOptions { get; set; } = new JsonSerializerOptions { - MetadataPropertyHandling = MetadataPropertyHandling.Ignore, - NullValueHandling = NullValueHandling.Ignore, - ContractResolver = new CamelCasePropertyNamesContractResolver(), - Converters = new List + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + Converters = { - new StringEnumConverter(new CamelCaseNamingStrategy()), - new ValueClassConverter() + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), + new ValueClassConverter(), + new ObjectToInferredTypesConverter() } }; - public static JsonSerializerSettings SerializerSettings { get; set; } = new JsonSerializerSettings + public static JsonSerializerOptions SerializerOptions { get; set; } = new JsonSerializerOptions { - NullValueHandling = NullValueHandling.Ignore, - ContractResolver = new CamelCasePropertyNamesContractResolver(), - Converters = new List + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { - new StringEnumConverter(new CamelCaseNamingStrategy()), - new ValueClassConverter() + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), + new ValueClassConverter(), + new ObjectToInferredTypesConverter() } }; @@ -69,14 +69,14 @@ namespace {{ spec.title | caseUcfirst }} _headers = new Dictionary() { { "content-type", "application/json" }, - { "user-agent" , "{{spec.title | caseUcfirst}}{{ language.name | caseUcfirst }}SDK/{{ sdk.version }} (${Environment.OSVersion.Platform}; ${Environment.OSVersion.VersionString})"}, + { "user-agent" , $"{{spec.title | caseUcfirst}}{{ language.name | caseUcfirst }}SDK/{{ sdk.version }} ({Environment.OSVersion.Platform}; {Environment.OSVersion.VersionString})"}, { "x-sdk-name", "{{ sdk.name }}" }, { "x-sdk-platform", "{{ sdk.platform }}" }, { "x-sdk-language", "{{ language.name | caseLower }}" }, - { "x-sdk-version", "{{ sdk.version }}"}{% if spec.global.defaultHeaders | length > 0 %},{% endif %} + { "x-sdk-version", "{{ sdk.version }}"}{% if spec.global.defaultHeaders | length > 0 %}, {%~ for key,header in spec.global.defaultHeaders %} { "{{key}}", "{{header}}" }{% if not loop.last %},{% endif %} - {%~ endfor %} + {%~ endfor %}{% endif %} }; @@ -86,8 +86,6 @@ namespace {{ spec.title | caseUcfirst }} { SetSelfSigned(true); } - - JsonConvert.DefaultSettings = () => DeserializerSettings; } public Client SetSelfSigned(bool selfSigned) @@ -158,19 +156,23 @@ namespace {{ spec.title | caseUcfirst }} { if (parameter.Key == "file") { - form.Add(((MultipartFormDataContent)parameters["file"]).First()!); + var fileContent = parameters["file"] as MultipartFormDataContent; + if (fileContent != null) + { + form.Add(fileContent.First()!); + } } else if (parameter.Value is IEnumerable enumerable) { var list = new List(enumerable); for (int index = 0; index < list.Count; index++) { - form.Add(new StringContent(list[index].ToString()!), $"{parameter.Key}[{index}]"); + form.Add(new StringContent(list[index]?.ToString() ?? string.Empty), $"{parameter.Key}[{index}]"); } } else { - form.Add(new StringContent(parameter.Value.ToString()!), parameter.Key); + form.Add(new StringContent(parameter.Value?.ToString() ?? string.Empty), parameter.Key); } } request.Content = form; @@ -243,8 +245,19 @@ namespace {{ spec.title | caseUcfirst }} } if (contentType.Contains("application/json")) { - message = JObject.Parse(text)["message"]!.ToString(); - type = JObject.Parse(text)["type"]?.ToString() ?? string.Empty; + try + { + using var errorDoc = JsonDocument.Parse(text); + message = errorDoc.RootElement.GetProperty("message").GetString() ?? ""; + if (errorDoc.RootElement.TryGetProperty("type", out var typeElement)) + { + type = typeElement.GetString() ?? ""; + } + } + catch + { + message = text; + } } else { message = text; } @@ -252,7 +265,7 @@ namespace {{ spec.title | caseUcfirst }} throw new {{spec.title | caseUcfirst}}Exception(message, code, type, text); } - return response.Headers.Location.OriginalString; + return response.Headers.Location?.OriginalString ?? string.Empty; } public Task> Call( @@ -298,8 +311,19 @@ namespace {{ spec.title | caseUcfirst }} var type = ""; if (isJson) { - message = JObject.Parse(text)["message"]!.ToString(); - type = JObject.Parse(text)["type"]?.ToString() ?? string.Empty; + try + { + using var errorDoc = JsonDocument.Parse(text); + message = errorDoc.RootElement.GetProperty("message").GetString() ?? ""; + if (errorDoc.RootElement.TryGetProperty("type", out var typeElement)) + { + type = typeElement.GetString() ?? ""; + } + } + catch + { + message = text; + } } else { message = text; } @@ -311,13 +335,13 @@ namespace {{ spec.title | caseUcfirst }} { var responseString = await response.Content.ReadAsStringAsync(); - var dict = JsonConvert.DeserializeObject>( + var dict = JsonSerializer.Deserialize>( responseString, - DeserializerSettings); + DeserializerOptions); - if (convert != null) + if (convert != null && dict != null) { - return convert(dict!); + return convert(dict); } return (dict as T)!; @@ -337,7 +361,16 @@ namespace {{ spec.title | caseUcfirst }} string? idParamName = null, Action? onProgress = null) where T : class { + if (string.IsNullOrEmpty(paramName)) + throw new ArgumentException("Parameter name cannot be null or empty", nameof(paramName)); + + if (!parameters.ContainsKey(paramName)) + throw new ArgumentException($"Parameter {paramName} not found", nameof(paramName)); + var input = parameters[paramName] as InputFile; + if (input == null) + throw new ArgumentException($"Parameter {paramName} must be an InputFile", nameof(paramName)); + var size = 0L; switch(input.SourceType) { @@ -347,10 +380,16 @@ namespace {{ spec.title | caseUcfirst }} size = info.Length; break; case "stream": - size = (input.Data as Stream).Length; + var stream = input.Data as Stream; + if (stream == null) + throw new InvalidOperationException("Stream data is null"); + size = stream.Length; break; case "bytes": - size = ((byte[])input.Data).Length; + var bytes = input.Data as byte[]; + if (bytes == null) + throw new InvalidOperationException("Byte array data is null"); + size = bytes.Length; break; }; @@ -364,10 +403,16 @@ namespace {{ spec.title | caseUcfirst }} { case "path": case "stream": - await (input.Data as Stream).ReadAsync(buffer, 0, (int)size); + var dataStream = input.Data as Stream; + if (dataStream == null) + throw new InvalidOperationException("Stream data is null"); + await dataStream.ReadAsync(buffer, 0, (int)size); break; case "bytes": - buffer = (byte[])input.Data; + var dataBytes = input.Data as byte[]; + if (dataBytes == null) + throw new InvalidOperationException("Byte array data is null"); + buffer = dataBytes; break; } @@ -393,14 +438,16 @@ namespace {{ spec.title | caseUcfirst }} // Make a request to check if a file already exists var current = await Call>( method: "GET", - path: $"{path}/{parameters[idParamName]}", + path: $"{path}/{parameters[idParamName!]}", new Dictionary { { "content-type", "application/json" } }, parameters: new Dictionary() ); - var chunksUploaded = (long)current["chunksUploaded"]; - offset = chunksUploaded * ChunkSize; + if (current.TryGetValue("chunksUploaded", out var chunksUploadedValue) && chunksUploadedValue != null) + { + offset = Convert.ToInt64(chunksUploadedValue) * ChunkSize; + } } - catch (Exception ex) + catch { // ignored as it mostly means file not found } @@ -413,6 +460,8 @@ namespace {{ spec.title | caseUcfirst }} case "path": case "stream": var stream = input.Data as Stream; + if (stream == null) + throw new InvalidOperationException("Stream data is null"); stream.Seek(offset, SeekOrigin.Begin); await stream.ReadAsync(buffer, 0, ChunkSize); break; @@ -445,12 +494,12 @@ namespace {{ spec.title | caseUcfirst }} var id = result.ContainsKey("$id") ? result["$id"]?.ToString() ?? string.Empty : string.Empty; - var chunksTotal = result.ContainsKey("chunksTotal") - ? (long)result["chunksTotal"] - : 0; - var chunksUploaded = result.ContainsKey("chunksUploaded") - ? (long)result["chunksUploaded"] - : 0; + var chunksTotal = result.TryGetValue("chunksTotal", out var chunksTotalValue) && chunksTotalValue != null + ? Convert.ToInt64(chunksTotalValue) + : 0L; + var chunksUploaded = result.TryGetValue("chunksUploaded", out var chunksUploadedValue) && chunksUploadedValue != null + ? Convert.ToInt64(chunksUploadedValue) + : 0L; headers["x-appwrite-id"] = id; @@ -463,7 +512,11 @@ namespace {{ spec.title | caseUcfirst }} chunksUploaded: chunksUploaded)); } - return converter(result); + // Convert to non-nullable dictionary for converter + var nonNullableResult = result.Where(kvp => kvp.Value != null) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!); + + return converter(nonNullableResult); } } } diff --git a/templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig b/templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig new file mode 100644 index 000000000..563f92992 --- /dev/null +++ b/templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace {{ spec.title | caseUcfirst }}.Converters +{ + public class ObjectToInferredTypesConverter : JsonConverter + { + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.Number: + if (reader.TryGetInt64(out long l)) + { + return l; + } + return reader.GetDouble(); + case JsonTokenType.String: + if (reader.TryGetDateTime(out DateTime datetime)) + { + return datetime; + } + return reader.GetString()!; + case JsonTokenType.StartObject: + return JsonSerializer.Deserialize>(ref reader, options)!; + case JsonTokenType.StartArray: + return JsonSerializer.Deserialize(ref reader, options)!; + default: + return JsonDocument.ParseValue(ref reader).RootElement.Clone(); + } + } + + public override void Write(Utf8JsonWriter writer, object objectToWrite, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options); + } + } +} diff --git a/templates/dotnet/Package/Converters/ValueClassConverter.cs.twig b/templates/dotnet/Package/Converters/ValueClassConverter.cs.twig index af68ab567..1b4fda368 100644 --- a/templates/dotnet/Package/Converters/ValueClassConverter.cs.twig +++ b/templates/dotnet/Package/Converters/ValueClassConverter.cs.twig @@ -1,38 +1,39 @@ using System; +using System.Text.Json; +using System.Text.Json.Serialization; using {{ spec.title | caseUcfirst }}.Enums; -using Newtonsoft.Json; namespace {{ spec.title | caseUcfirst }}.Converters { - public class ValueClassConverter : JsonConverter { - - public override bool CanConvert(System.Type objectType) + public class ValueClassConverter : JsonConverter + { + public override bool CanConvert(Type objectType) { return typeof(IEnum).IsAssignableFrom(objectType); } - public override object ReadJson(JsonReader reader, System.Type objectType, object existingValue, JsonSerializer serializer) + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var value = (string)reader.Value; - var constructor = objectType.GetConstructor(new[] { typeof(string) }); - var obj = constructor.Invoke(new object[] { value }); + var value = reader.GetString(); + var constructor = typeToConvert.GetConstructor(new[] { typeof(string) }); + var obj = constructor?.Invoke(new object[] { value! }); - return Convert.ChangeType(obj, objectType); + return Convert.ChangeType(obj, typeToConvert)!; } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) { var type = value.GetType(); var property = type.GetProperty(nameof(IEnum.Value)); - var propertyValue = property.GetValue(value); + var propertyValue = property?.GetValue(value); if (propertyValue == null) { - writer.WriteNull(); + writer.WriteNullValue(); return; } - writer.WriteValue(propertyValue); + writer.WriteStringValue(propertyValue.ToString()); } } } diff --git a/templates/dotnet/Package/Extensions/Extensions.cs.twig b/templates/dotnet/Package/Extensions/Extensions.cs.twig index 10b2b5035..d57318077 100644 --- a/templates/dotnet/Package/Extensions/Extensions.cs.twig +++ b/templates/dotnet/Package/Extensions/Extensions.cs.twig @@ -1,7 +1,7 @@ -using Newtonsoft.Json; -using System; +using System; using System.Collections; using System.Collections.Generic; +using System.Text.Json; namespace {{ spec.title | caseUcfirst }}.Extensions { @@ -9,7 +9,7 @@ namespace {{ spec.title | caseUcfirst }}.Extensions { public static string ToJson(this Dictionary dict) { - return JsonConvert.SerializeObject(dict, Client.SerializerSettings); + return JsonSerializer.Serialize(dict, Client.SerializerOptions); } public static string ToQueryString(this Dictionary parameters) diff --git a/templates/dotnet/Package/Models/InputFile.cs.twig b/templates/dotnet/Package/Models/InputFile.cs.twig index 5d98b2167..241a3adad 100644 --- a/templates/dotnet/Package/Models/InputFile.cs.twig +++ b/templates/dotnet/Package/Models/InputFile.cs.twig @@ -5,11 +5,11 @@ namespace {{ spec.title | caseUcfirst }}.Models { public class InputFile { - public string Path { get; set; } - public string Filename { get; set; } - public string MimeType { get; set; } - public string SourceType { get; set; } - public object Data { get; set; } + public string Path { get; set; } = string.Empty; + public string Filename { get; set; } = string.Empty; + public string MimeType { get; set; } = string.Empty; + public string SourceType { get; set; } = string.Empty; + public object Data { get; set; } = new object(); public static InputFile FromPath(string path) => new InputFile { diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index bb4dc24b9..ff46ff18e 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -4,16 +4,15 @@ using System; using System.Linq; using System.Collections.Generic; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; namespace {{ spec.title | caseUcfirst }}.Models { public class {{ definition.name | caseUcfirst | overrideIdentifier }} { {%~ for property in definition.properties %} - [JsonProperty("{{ property.name }}")] + [JsonPropertyName("{{ property.name }}")] public {{ _self.sub_schema(property) }} {{ _self.property_name(definition, property) | overrideProperty(definition.name) }} { get; private set; } {%~ endfor %} @@ -43,13 +42,13 @@ namespace {{ spec.title | caseUcfirst }}.Models {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}:{{' '}} {%- if property.sub_schema %} {%- if property.type == 'array' -%} - ((JArray)map["{{ property.name }}"]).ToObject>>().Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() + map["{{ property.name }}"] is JsonElement jsonArray{{ loop.index }} ? jsonArray{{ loop.index }}.Deserialize>>()!.Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() : ((IEnumerable>)map["{{ property.name }}"]).Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() {%- else -%} - {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: ((JObject)map["{{ property.name }}"]).ToObject>()!) + {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: map["{{ property.name }}"] is JsonElement jsonObj{{ loop.index }} ? jsonObj{{ loop.index }}.Deserialize>()! : (Dictionary)map["{{ property.name }}"]) {%- endif %} {%- else %} {%- if property.type == 'array' -%} - ((JArray)map["{{ property.name }}"]).ToObject<{{ property | typeName }}>() + map["{{ property.name }}"] is JsonElement jsonArrayProp{{ loop.index }} ? jsonArrayProp{{ loop.index }}.Deserialize<{{ property | typeName }}>()! : ({{ property | typeName }})map["{{ property.name }}"] {%- else %} {%- if property.type == "integer" or property.type == "number" %} {%- if not property.required -%}map["{{ property.name }}"] == null ? null :{% endif %}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}(map["{{ property.name }}"]) diff --git a/templates/dotnet/Package/Package.csproj.twig b/templates/dotnet/Package/Package.csproj.twig index 0134308a5..5b5ad4cc5 100644 --- a/templates/dotnet/Package/Package.csproj.twig +++ b/templates/dotnet/Package/Package.csproj.twig @@ -1,6 +1,6 @@ - netstandard2.0;net461 + netstandard2.0;net462 {{spec.title}} {{sdk.version}} {{spec.contactName}} @@ -20,7 +20,7 @@ - + diff --git a/templates/dotnet/Package/Query.cs.twig b/templates/dotnet/Package/Query.cs.twig index 3b26a02a1..18359f30c 100644 --- a/templates/dotnet/Package/Query.cs.twig +++ b/templates/dotnet/Package/Query.cs.twig @@ -1,39 +1,49 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; -namespace Appwrite +namespace {{ spec.title | caseUcfirst }} { public class Query { - public string method; - public string? attribute; - public List? values; + [JsonPropertyName("method")] + public string Method { get; set; } = string.Empty; + + [JsonPropertyName("attribute")] + public string? Attribute { get; set; } + + [JsonPropertyName("values")] + public List? Values { get; set; } + + public Query() + { + } public Query(string method, string? attribute, object? values) { - this.method = method; - this.attribute = attribute; + this.Method = method; + this.Attribute = attribute; if (values is IList valuesList) { - this.values = new List(); + this.Values = new List(); foreach (var value in valuesList) { - this.values.Add(value); // Automatically boxes if value is a value type + this.Values.Add(value); // Automatically boxes if value is a value type } } else if (values != null) { - this.values = new List { values }; + this.Values = new List { values }; } } override public string ToString() { - return JsonConvert.SerializeObject(this); + return JsonSerializer.Serialize(this, Client.SerializerOptions); } public static string Equal(string attribute, object value) @@ -141,11 +151,11 @@ namespace Appwrite } public static string Or(List queries) { - return new Query("or", null, queries.Select(q => JsonConvert.DeserializeObject(q)).ToList()).ToString(); + return new Query("or", null, queries.Select(q => JsonSerializer.Deserialize(q, Client.DeserializerOptions)).ToList()).ToString(); } public static string And(List queries) { - return new Query("and", null, queries.Select(q => JsonConvert.DeserializeObject(q)).ToList()).ToString(); + return new Query("and", null, queries.Select(q => JsonSerializer.Deserialize(q, Client.DeserializerOptions)).ToList()).ToString(); } } } \ No newline at end of file diff --git a/templates/dotnet/README.md.twig b/templates/dotnet/README.md.twig index 86b47f298..0acd3ea54 100644 --- a/templates/dotnet/README.md.twig +++ b/templates/dotnet/README.md.twig @@ -47,24 +47,24 @@ dotnet add package {{ spec.title | caseUcfirst }} --version {{ sdk.version }} ### Preparing Models for Databases API -For the .NET SDK, we use the `Newtonsoft.Json` library for serialization/deserialization support. The default behavior converts property names from `PascalCase` to `camelCase` on serializing to JSON. In case the names of attributes in your Appwrite collection are not created in `camelCase`, this serializer behavior can cause errors due to mismatches in the names in the serialized JSON and the actual attribute names in your collection. +For the .NET SDK, we use the `System.Text.Json` library for serialization/deserialization support. The default behavior converts property names from `PascalCase` to `camelCase` on serializing to JSON. In case the names of attributes in your Appwrite collection are not created in `camelCase`, this serializer behavior can cause errors due to mismatches in the names in the serialized JSON and the actual attribute names in your collection. -The way to fix this is to add the `JsonProperty` attribute to the properties in the POCO class you create for your model. +The way to fix this is to add the `JsonPropertyName` attribute to the properties in the POCO class you create for your model. For e.g., if you have two attributes, `name` (`string` type) and `release_date` (`DateTime` type), your POCO class would be created as follows: ```csharp public class TestModel { - [JsonProperty("name")] + [JsonPropertyName("name")] public string Name { get; set; } - [JsonProperty("release_date")] + [JsonPropertyName("release_date")] public DateTime ReleaseDate { get; set; } } ``` -The `JsonProperty` attribute will ensure that your data object for the Appwrite database is serialized with the correct names. +The `JsonPropertyName` attribute will ensure that your data object for the Appwrite database is serialized with the correct names. ## Contribution diff --git a/tests/DotNet90Test.php b/tests/DotNet90Test.php new file mode 100644 index 000000000..e8ea86710 --- /dev/null +++ b/tests/DotNet90Test.php @@ -0,0 +1,34 @@ + + + net9.0 + latest + false + + + + + + + + +