Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions codegen/internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 != ""
Expand All @@ -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
}
Expand Down Expand Up @@ -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<string, JsonElement>", 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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, JsonElement>", 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:
Expand Down Expand Up @@ -1410,6 +1434,7 @@ type modelTemplateData struct {
HasExtensionData bool
ExtensionDataValueType string
IsDictionaryModel bool
DictionaryBaseType string
DictionaryValueType string
EmitToString bool
}
Expand Down
111 changes: 111 additions & 0 deletions codegen/internal/generator/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions codegen/internal/generator/templates/model_class.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ using System.Text;
/// <summary>{{ .Description }}</summary>
{{- end }}
{{- if .IsDictionaryModel }}
{{- if .DictionaryBaseType }}
public sealed partial class {{ .Name }} : {{ .DictionaryBaseType }}
{
}
{{- else }}
public sealed partial class {{ .Name }} : Dictionary<string, {{ .DictionaryValueType }}>
{
}
{{- end }}
{{- else }}
public sealed partial class {{ .Name }}
{
Expand Down
67 changes: 67 additions & 0 deletions src/SumUp.Tests/JsonObjectTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>("status"));
Assert.Equal(2L, result.GetValue<long>("count"));

Assert.True(result.TryGetValue("items", out var itemsValue));
var items = Assert.IsType<List<object?>>(itemsValue);
Assert.Equal("one", Assert.IsType<string>(items[0]));

var nested = Assert.IsType<JsonObject>(items[1]);
Assert.True(nested.GetValue<bool>("nested"));
}
}
Loading
Loading