diff --git a/codegen/internal/generator/generator.go b/codegen/internal/generator/generator.go index c7abae7..f954bee 100644 --- a/codegen/internal/generator/generator.go +++ b/codegen/internal/generator/generator.go @@ -355,6 +355,7 @@ func (g *Generator) buildClassModel(typeName string, schema *base.Schema) (model return modelTemplateData{}, err } extensionType := "" + dictionaryBaseType := "" if schema.AdditionalProperties != nil { if schema.AdditionalProperties.IsA() && schema.AdditionalProperties.A != nil { val := g.resolveType(schema.AdditionalProperties.A, true) @@ -364,9 +365,9 @@ func (g *Generator) buildClassModel(typeName string, schema *base.Schema) (model usesJson = true } } else if schema.AdditionalProperties.IsB() && schema.AdditionalProperties.B { - extensionType = "JsonElement" + extensionType = "object?" + dictionaryBaseType = "JsonObject" usesCollections = true - usesJson = true } } isDictionaryModel := len(props) == 0 && extensionType != "" @@ -382,6 +383,7 @@ func (g *Generator) buildClassModel(typeName string, schema *base.Schema) (model HasExtensionData: extensionType != "" && !isDictionaryModel, ExtensionDataValueType: extensionType, IsDictionaryModel: isDictionaryModel, + DictionaryBaseType: dictionaryBaseType, DictionaryValueType: extensionType, }, nil } @@ -834,7 +836,7 @@ func (g *Generator) resolveInlineSchemaType(schemaRef *base.SchemaProxy, require return g.nullableType(typeName, false, required, true), nil } if schema.AdditionalProperties != nil && schema.AdditionalProperties.IsB() && schema.AdditionalProperties.B { - return g.nullableType("IDictionary", false, required, true), nil + return g.nullableType("JsonObject", false, required), nil } if schema.Items != nil && schema.Items.IsA() { itemInfo, err := g.resolveInlineSchemaType(schema.Items.A, true, inlineBase+"Item") @@ -1052,9 +1054,31 @@ func (g *Generator) responseTypeForResponse(resp *v3.Response, inlineBase string if schemaRef == nil { return typeInfo{}, nil } + if schema := g.schemaFromProxy(schemaRef); schema != nil && isOpaqueObjectSchema(schema) { + return g.nullableType("JsonDocument", false, true), nil + } return g.resolveInlineSchemaType(schemaRef, true, inlineBase) } +func isOpaqueObjectSchema(schema *base.Schema) bool { + if schema == nil { + return false + } + if !schemaHasType(schema, "object") { + return false + } + if schema.Properties != nil && schema.Properties.Len() > 0 { + return false + } + if len(schema.AllOf) > 0 { + return false + } + if schema.AdditionalProperties != nil { + return false + } + return true +} + func (g *Generator) resolveResponseMode(op *v3.Operation, responseInfo typeInfo) (string, error) { if op == nil || op.Responses == nil || op.Responses.Codes == nil || op.Responses.Codes.Len() == 0 { return "none", nil @@ -1280,10 +1304,10 @@ func (g *Generator) resolveType(schemaRef *base.SchemaProxy, required bool) type return g.nullableType(typeName, false, required, true) } if schema.AdditionalProperties != nil && schema.AdditionalProperties.IsB() && schema.AdditionalProperties.B { - return g.nullableType("IDictionary", false, required, true) + return g.nullableType("JsonObject", false, required) } if (schema.Properties == nil || schema.Properties.Len() == 0) && len(schema.AllOf) == 0 { - return g.nullableType("JsonDocument", false, required) + return g.nullableType("JsonObject", false, required) } return g.nullableType("JsonDocument", false, required) default: @@ -1410,6 +1434,7 @@ type modelTemplateData struct { HasExtensionData bool ExtensionDataValueType string IsDictionaryModel bool + DictionaryBaseType string DictionaryValueType string EmitToString bool } diff --git a/codegen/internal/generator/generator_test.go b/codegen/internal/generator/generator_test.go index 8453695..36c0c44 100644 --- a/codegen/internal/generator/generator_test.go +++ b/codegen/internal/generator/generator_test.go @@ -80,6 +80,117 @@ func TestBuildModels_GeneratesInlineEnumForLinksRelation(t *testing.T) { } } +func TestBuildModels_UsesJsonObjectForFreeFormObjects(t *testing.T) { + const spec = `{ + "openapi": "3.0.3", + "info": { + "title": "test", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Metadata": { + "type": "object", + "additionalProperties": true + }, + "PaymentPayload": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "additionalProperties": true + }, + "apple_pay": { + "type": "object" + } + } + } + } + } + }` + + doc := mustBuildV3Document(t, spec) + + g := New(Config{Namespace: "SumUp"}) + models, err := g.buildModels(doc) + if err != nil { + t.Fatalf("buildModels() error = %v", err) + } + + metadata := findModel(t, models, "Metadata") + if !metadata.IsDictionaryModel { + t.Fatalf("Metadata should be a dictionary-backed model") + } + if metadata.DictionaryBaseType != "JsonObject" { + t.Fatalf("Metadata dictionary base type = %q, want %q", metadata.DictionaryBaseType, "JsonObject") + } + + payload := findModel(t, models, "PaymentPayload") + if got := propertyType(payload.Properties, "Metadata"); got != "JsonObject?" { + t.Fatalf("Metadata property type = %q, want %q", got, "JsonObject?") + } + if got := propertyType(payload.Properties, "ApplePay"); got != "JsonObject?" { + t.Fatalf("ApplePay property type = %q, want %q", got, "JsonObject?") + } +} + +func TestBuildClients_UsesJsonDocumentForOpaqueObjectResponses(t *testing.T) { + const spec = `{ + "openapi": "3.0.3", + "info": { + "title": "test", + "version": "1.0.0" + }, + "paths": { + "/v0.2/checkouts/{id}/apple-pay-session": { + "put": { + "tags": ["Checkouts"], + "operationId": "CreateApplePaySession", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + } + } + } + } + } + }` + + doc := mustBuildV3Document(t, spec) + + g := New(Config{Namespace: "SumUp"}) + clients, err := g.buildClients(doc) + if err != nil { + t.Fatalf("buildClients() error = %v", err) + } + + if len(clients) != 1 || len(clients[0].Operations) != 1 { + t.Fatalf("unexpected client/operation count") + } + + operation := clients[0].Operations[0] + if operation.ResponseType != "JsonDocument" { + t.Fatalf("response type = %q, want %q", operation.ResponseType, "JsonDocument") + } + if operation.ResponseMode != "json-document" { + t.Fatalf("response mode = %q, want %q", operation.ResponseMode, "json-document") + } +} + func mustBuildV3Document(t *testing.T, raw string) *v3.Document { t.Helper() diff --git a/codegen/internal/generator/templates/model_class.tmpl b/codegen/internal/generator/templates/model_class.tmpl index e40dfce..21fa4f2 100644 --- a/codegen/internal/generator/templates/model_class.tmpl +++ b/codegen/internal/generator/templates/model_class.tmpl @@ -19,9 +19,15 @@ using System.Text; /// {{ .Description }} {{- end }} {{- if .IsDictionaryModel }} +{{- if .DictionaryBaseType }} +public sealed partial class {{ .Name }} : {{ .DictionaryBaseType }} +{ +} +{{- else }} public sealed partial class {{ .Name }} : Dictionary { } +{{- end }} {{- else }} public sealed partial class {{ .Name }} { diff --git a/src/SumUp.Tests/JsonObjectTests.cs b/src/SumUp.Tests/JsonObjectTests.cs new file mode 100644 index 0000000..42ffe29 --- /dev/null +++ b/src/SumUp.Tests/JsonObjectTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using SumUp.Http; +using Xunit; + +namespace SumUp.Tests; + +public class JsonObjectTests +{ + [Fact] + public void CreateContent_SerializesJsonObjectPayloads() + { + using var httpClient = new HttpClient { BaseAddress = new Uri("https://api.sumup.com") }; + var apiClient = new ApiClient(httpClient, new SumUpClientOptions()); + var request = new ProcessCheckout + { + PaymentType = ProcessCheckoutPaymentType.Card, + ApplePay = new JsonObject + { + ["token"] = new JsonObject + { + ["version"] = "EC_v1" + }, + ["sandbox"] = true, + }, + }; + + using var content = apiClient.CreateContent(request, "application/json"); + using var stream = content.ReadAsStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + var body = reader.ReadToEnd(); + + Assert.Contains("\"apple_pay\":{\"token\":{\"version\":\"EC_v1\"},\"sandbox\":true}", body); + } + + [Fact] + public void Parse_ReadsNestedObjectsAndArrays() + { + var json = """ + { + "status": "ok", + "count": 2, + "items": [ + "one", + { + "nested": true + } + ] + } + """; + + var result = JsonObject.Parse(json); + + Assert.Equal("ok", result.GetValue("status")); + Assert.Equal(2L, result.GetValue("count")); + + Assert.True(result.TryGetValue("items", out var itemsValue)); + var items = Assert.IsType>(itemsValue); + Assert.Equal("one", Assert.IsType(items[0])); + + var nested = Assert.IsType(items[1]); + Assert.True(nested.GetValue("nested")); + } +} diff --git a/src/SumUp/JsonObject.cs b/src/SumUp/JsonObject.cs new file mode 100644 index 0000000..c048749 --- /dev/null +++ b/src/SumUp/JsonObject.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SumUp; + +/// +/// Mutable JSON object helper used for free-form request and response payloads. +/// +[JsonConverter(typeof(JsonObjectConverter))] +public class JsonObject : Dictionary +{ + /// + /// Parse a JSON object string into a . + /// + public static JsonObject Parse(string json, JsonSerializerOptions? options = null) + { + if (json is null) + { + throw new ArgumentNullException(nameof(json)); + } + + return JsonSerializer.Deserialize(json, options) ?? new JsonObject(); + } + + /// + /// Read a stored value and convert it to the requested type. + /// + public T? GetValue(string propertyName, JsonSerializerOptions? options = null) + { + if (!TryGetValue(propertyName, out var value)) + { + return default; + } + + if (value is T typedValue) + { + return typedValue; + } + + var payload = JsonSerializer.SerializeToUtf8Bytes(value, options); + return JsonSerializer.Deserialize(payload, options); + } +} + +internal sealed class JsonObjectConverter : JsonConverter +{ + public override JsonObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected JSON object."); + } + + var result = new JsonObject(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return result; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected JSON property name."); + } + + var propertyName = reader.GetString() ?? throw new JsonException("Property name cannot be null."); + reader.Read(); + result[propertyName] = ReadValue(ref reader, options); + } + + throw new JsonException("Incomplete JSON object."); + } + + public override void Write(Utf8JsonWriter writer, JsonObject value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + foreach (var pair in value) + { + writer.WritePropertyName(pair.Key); + JsonSerializer.Serialize(writer, pair.Value, options); + } + + writer.WriteEndObject(); + } + + private static object? ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + return JsonSerializer.Deserialize(ref reader, options); + case JsonTokenType.StartArray: + return ReadArray(ref reader, options); + case JsonTokenType.String: + return reader.GetString(); + case JsonTokenType.Number: + if (reader.TryGetInt64(out var longValue)) + { + return longValue; + } + if (reader.TryGetDecimal(out var decimalValue)) + { + return decimalValue; + } + return reader.GetDouble(); + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.Null: + return null; + default: + throw new JsonException($"Unsupported token type {reader.TokenType}."); + } + } + + private static List ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var values = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + return values; + } + + values.Add(ReadValue(ref reader, options)); + } + + throw new JsonException("Incomplete JSON array."); + } +} diff --git a/src/SumUp/Models/Attributes.g.cs b/src/SumUp/Models/Attributes.g.cs index 5cc4da2..56caae8 100644 --- a/src/SumUp/Models/Attributes.g.cs +++ b/src/SumUp/Models/Attributes.g.cs @@ -5,8 +5,7 @@ namespace SumUp; using System.Text.Json.Serialization; using System.Collections.Generic; -using System.Text.Json; /// Object attributes that are modifiable only by SumUp applications. -public sealed partial class Attributes : Dictionary +public sealed partial class Attributes : JsonObject { } \ No newline at end of file diff --git a/src/SumUp/Models/CreateReaderCheckoutRequestAffiliate.g.cs b/src/SumUp/Models/CreateReaderCheckoutRequestAffiliate.g.cs index 6cafbb9..7d590fe 100644 --- a/src/SumUp/Models/CreateReaderCheckoutRequestAffiliate.g.cs +++ b/src/SumUp/Models/CreateReaderCheckoutRequestAffiliate.g.cs @@ -4,8 +4,6 @@ namespace SumUp; using System.Text.Json.Serialization; -using System.Collections.Generic; -using System.Text.Json; /// Affiliate metadata for the transaction. It is a field that allow for integrators to track the source of the transaction. public sealed partial class CreateReaderCheckoutRequestAffiliate { @@ -20,5 +18,5 @@ public sealed partial class CreateReaderCheckoutRequestAffiliate public string Key { get; set; } = default!; /// Additional metadata for the transaction. It is key-value object that can be associated with the transaction. [JsonPropertyName("tags")] - public IDictionary? Tags { get; set; } + public JsonObject? Tags { get; set; } } \ No newline at end of file diff --git a/src/SumUp/Models/CreateReaderCheckoutUnprocessableEntity.g.cs b/src/SumUp/Models/CreateReaderCheckoutUnprocessableEntity.g.cs index 450ea57..7be0f98 100644 --- a/src/SumUp/Models/CreateReaderCheckoutUnprocessableEntity.g.cs +++ b/src/SumUp/Models/CreateReaderCheckoutUnprocessableEntity.g.cs @@ -4,11 +4,9 @@ namespace SumUp; using System.Text.Json.Serialization; -using System.Collections.Generic; -using System.Text.Json; /// Unprocessable entity public sealed partial class CreateReaderCheckoutUnprocessableEntity { [JsonPropertyName("errors")] - public IDictionary Errors { get; set; } = default!; + public JsonObject Errors { get; set; } = default!; } \ No newline at end of file diff --git a/src/SumUp/Models/CreateReaderTerminateUnprocessableEntity.g.cs b/src/SumUp/Models/CreateReaderTerminateUnprocessableEntity.g.cs index b548bd1..5e5a31e 100644 --- a/src/SumUp/Models/CreateReaderTerminateUnprocessableEntity.g.cs +++ b/src/SumUp/Models/CreateReaderTerminateUnprocessableEntity.g.cs @@ -4,11 +4,9 @@ namespace SumUp; using System.Text.Json.Serialization; -using System.Collections.Generic; -using System.Text.Json; /// Unprocessable entity public sealed partial class CreateReaderTerminateUnprocessableEntity { [JsonPropertyName("errors")] - public IDictionary Errors { get; set; } = default!; + public JsonObject Errors { get; set; } = default!; } \ No newline at end of file diff --git a/src/SumUp/Models/Metadata.g.cs b/src/SumUp/Models/Metadata.g.cs index 7ff10c0..05916c7 100644 --- a/src/SumUp/Models/Metadata.g.cs +++ b/src/SumUp/Models/Metadata.g.cs @@ -5,8 +5,7 @@ namespace SumUp; using System.Text.Json.Serialization; using System.Collections.Generic; -using System.Text.Json; /// Set of user-defined key-value pairs attached to the object. Partial updates are not supported. When updating, always submit whole metadata. Maximum of 64 parameters are allowed in the object. -public sealed partial class Metadata : Dictionary +public sealed partial class Metadata : JsonObject { } \ No newline at end of file diff --git a/src/SumUp/Models/Problem.g.cs b/src/SumUp/Models/Problem.g.cs index 6dc6066..c331479 100644 --- a/src/SumUp/Models/Problem.g.cs +++ b/src/SumUp/Models/Problem.g.cs @@ -27,7 +27,7 @@ public sealed partial class Problem public string Type { get; set; } = default!; [JsonExtensionData] - public IDictionary AdditionalProperties { get; set; } = new Dictionary(); + public IDictionary AdditionalProperties { get; set; } = new Dictionary(); public override string ToString() { diff --git a/src/SumUp/Models/ProcessCheckout.g.cs b/src/SumUp/Models/ProcessCheckout.g.cs index ec11b04..9891a80 100644 --- a/src/SumUp/Models/ProcessCheckout.g.cs +++ b/src/SumUp/Models/ProcessCheckout.g.cs @@ -4,13 +4,12 @@ namespace SumUp; using System.Text.Json.Serialization; -using System.Text.Json; /// Request body for attempting payment on an existing checkout. The required companion fields depend on the selected `payment_type`, for example card details, saved-card data, or payer information required by a specific payment method. public sealed partial class ProcessCheckout { /// Raw payment token object received from Apple Pay. Send the Apple Pay response payload as-is. [JsonPropertyName("apple_pay")] - public JsonDocument? ApplePay { get; set; } + public JsonObject? ApplePay { get; set; } /// __Required when payment type is `card`.__ Details of the payment card. [JsonPropertyName("card")] public Card? Card { get; set; } @@ -19,7 +18,7 @@ public sealed partial class ProcessCheckout public string? CustomerId { get; set; } /// Raw `PaymentData` object received from Google Pay. Send the Google Pay response payload as-is. [JsonPropertyName("google_pay")] - public JsonDocument? GooglePay { get; set; } + public JsonObject? GooglePay { get; set; } /// Number of installments for deferred payments. Available only to merchant users in Brazil. [JsonPropertyName("installments")] public int? Installments { get; set; } diff --git a/src/SumUp/Models/Receipt.g.cs b/src/SumUp/Models/Receipt.g.cs index 8523328..22f3067 100644 --- a/src/SumUp/Models/Receipt.g.cs +++ b/src/SumUp/Models/Receipt.g.cs @@ -4,7 +4,6 @@ namespace SumUp; using System.Text.Json.Serialization; -using System.Text.Json; /// Receipt details for a transaction. public sealed partial class Receipt { @@ -13,7 +12,7 @@ public sealed partial class Receipt public ReceiptAcquirerData? AcquirerData { get; set; } /// EMV-specific metadata returned for card-present payments. [JsonPropertyName("emv_data")] - public JsonDocument? EmvData { get; set; } + public JsonObject? EmvData { get; set; } /// Receipt merchant data [JsonPropertyName("merchant_data")] public ReceiptMerchantData? MerchantData { get; set; }