Skip to content
Merged
4 changes: 4 additions & 0 deletions config/generation.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const patterns = [

'tests/output/csharp/src/Algolia.Search.Tests.csproj',
'!tests/output/csharp/src/TimeoutIntegrationTests.cs',
'!tests/output/csharp/src/Utils/**',

// Dart
'!clients/algoliasearch-client-dart/**',
Expand Down Expand Up @@ -64,6 +65,7 @@ export const patterns = [

'tests/output/java/build.gradle',
'!tests/output/java/src/test/java/com/algolia/manual/**',
'!tests/output/java/src/test/java/com/algolia/utils/**',

// JavaScript
'!clients/algoliasearch-client-javascript/*',
Expand Down Expand Up @@ -92,6 +94,8 @@ export const patterns = [
'clients/algoliasearch-client-kotlin/client/src/commonMain/kotlin/com/algolia/client/api/**',
'clients/algoliasearch-client-kotlin/client/src/commonMain/kotlin/com/algolia/client/model/**',

'!tests/output/kotlin/src/commonTest/kotlin/com/algolia/utils/TestHelpers.kt',

// PHP
'!clients/algoliasearch-client-php/**',
'clients/algoliasearch-client-php/lib/Api/*',
Expand Down
4 changes: 2 additions & 2 deletions templates/csharp/tests/e2e/e2e.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ using Algolia.Search.Serializer;
using Algolia.Search.Tests.Utils;
using Xunit;
using System.Text.Json;
using Quibble.Xunit;
using dotenv.net;
{{#isSearchClient}}
using Action = Algolia.Search.Models.Search.Action;
Expand Down Expand Up @@ -50,6 +49,7 @@ public class {{client}}RequestTestsE2E

}


{{#blocksE2E}}
{{#tests}}
[Fact(DisplayName = "{{{testName}}}")]
Expand All @@ -64,7 +64,7 @@ public class {{client}}RequestTestsE2E
{{/statusCode}}

{{#body}}
JsonAssert.EqualOverrideDefault("{{#lambda.escapeQuotes}}{{{.}}}{{/lambda.escapeQuotes}}", JsonSerializer.Serialize(resp, JsonConfig.Options), new JsonDiffConfig(true));
TestHelpers.LenientJsonAssert("{{#lambda.escapeQuotes}}{{{.}}}{{/lambda.escapeQuotes}}", JsonSerializer.Serialize(resp, JsonConfig.Options));
{{/body}}
} catch (Exception e)
{
Expand Down
4 changes: 2 additions & 2 deletions templates/go/api.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -386,11 +386,11 @@ func (c *APIClient) NewApi{{{nickname}}}Request({{#requiredParams}} {{paramName}

{{#allParams}}
{{^required}}
// With{{#lambda.titlecase}}{{baseName}}{{/lambda.titlecase}} adds the {{paramName}} to the {{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}Request and returns the request for chaining.
// With{{#nameInPascalCase}}{{nameInPascalCase}}{{/nameInPascalCase}}{{^nameInPascalCase}}{{#lambda.titlecase}}{{baseName}}{{/lambda.titlecase}}{{/nameInPascalCase}} adds the {{paramName}} to the {{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}Request and returns the request for chaining.
{{#isDeprecated}}
// Deprecated
{{/isDeprecated}}
func (r {{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}Request) With{{#lambda.titlecase}}{{baseName}}{{/lambda.titlecase}}({{paramName}} {{^isFreeFormObject}}{{^isArray}}{{^isMap}}{{^isPrimitiveType}}{{^isEnumRef}}*{{/isEnumRef}}{{/isPrimitiveType}}{{/isMap}}{{/isArray}}{{/isFreeFormObject}}{{{dataType}}}) {{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}Request {
func (r {{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}Request) With{{#nameInPascalCase}}{{nameInPascalCase}}{{/nameInPascalCase}}{{^nameInPascalCase}}{{#lambda.titlecase}}{{baseName}}{{/lambda.titlecase}}{{/nameInPascalCase}}({{paramName}} {{^isFreeFormObject}}{{^isArray}}{{^isMap}}{{^isPrimitiveType}}{{^isEnumRef}}*{{/isEnumRef}}{{/isPrimitiveType}}{{/isMap}}{{/isArray}}{{/isFreeFormObject}}{{{dataType}}}) {{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}Request {
r.{{paramName}} = {{#isPrimitiveType}}{{^isMap}}&{{/isMap}}{{/isPrimitiveType}}{{paramName}}
return r
}
Expand Down
16 changes: 14 additions & 2 deletions templates/go/search_helpers.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,13 @@ func (c *APIClient) SaveObjectsWithTransformation(indexName string, objects []ma
return nil, reportError("`region` must be provided at client instantiation before calling this method.")
}

return c.ingestionTransporter.ChunkedPush(indexName, objects, ingestion.Action(ACTION_ADD_OBJECT), nil, toIngestionChunkedBatchOptions(opts)...) //nolint:wrapcheck
//nolint:wrapcheck
return c.ingestionTransporter.ChunkedPush(
indexName,
objects,
ingestion.Action(ACTION_ADD_OBJECT),
nil,
toIngestionChunkedBatchOptions(opts)...)
}

/*
Expand Down Expand Up @@ -836,5 +842,11 @@ func (c *APIClient) PartialUpdateObjectsWithTransformation(indexName string, obj
action = ACTION_PARTIAL_UPDATE_OBJECT_NO_CREATE
}

return c.ingestionTransporter.ChunkedPush(indexName, objects, ingestion.Action(action), nil, toIngestionChunkedBatchOptions(partialUpdateObjectsToChunkedBatchOptions(opts))...) //nolint:wrapcheck
//nolint:wrapcheck
return c.ingestionTransporter.ChunkedPush(
indexName,
objects,
ingestion.Action(action),
nil,
toIngestionChunkedBatchOptions(partialUpdateObjectsToChunkedBatchOptions(opts))...)
}
2 changes: 1 addition & 1 deletion templates/go/tests/method.mustache
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
client.{{#lambda.titlecase}}{{method}}{{/lambda.titlecase}}({{#hasParams}}{{^isHelper}}client.NewApi{{#lambda.titlecase}}{{method}}{{/lambda.titlecase}}Request({{/isHelper}}
{{#parametersWithDataType}}{{#required}}{{> tests/generateParams}},{{/required}}{{/parametersWithDataType}} {{^isHelper}}){{#parametersWithDataType}}{{^required}}.With{{#lambda.pascalcase}}{{{key}}}{{/lambda.pascalcase}}({{> tests/generateParams}}){{#-last}},{{/-last}}{{/required}}{{/parametersWithDataType}}{{/isHelper}}{{#isHelper}}{{#parametersWithDataType}}{{^required}}{{> tests/generateParams}},{{/required}}{{/parametersWithDataType}}{{/isHelper}}{{/hasParams}}{{#requestOptions}}{{#queryParameters.parametersWithDataType}}{{clientPrefix}}.WithQueryParam("{{{key}}}", {{> tests/generateInnerParams}}),{{/queryParameters.parametersWithDataType}}{{#headers.parametersWithDataType}}{{clientPrefix}}.WithHeaderParam("{{{key}}}", {{> tests/generateInnerParams}}),{{/headers.parametersWithDataType}} {{#timeouts.read}} ,{{clientPrefix}}.WithReadTimeout({{.}} * time.Millisecond), {{/timeouts.read}} {{#timeouts.write}} ,{{clientPrefix}}.WithWriteTimeout({{.}} * time.Millisecond), {{/timeouts.write}} {{#timeouts.connect}} ,{{clientPrefix}}.WithConnectTimeout({{.}} * time.Millisecond), {{/timeouts.connect}} {{/requestOptions}})
{{#parametersWithDataType}}{{#required}}{{> tests/generateParams}},{{/required}}{{/parametersWithDataType}} {{^isHelper}}){{#parametersWithDataType}}{{^required}}{{^isNull}}.With{{#lambda.pascalcase}}{{{key}}}{{/lambda.pascalcase}}({{> tests/generateParams}}){{/isNull}}{{#-last}},{{/-last}}{{/required}}{{/parametersWithDataType}}{{/isHelper}}{{#isHelper}}{{#parametersWithDataType}}{{^required}}{{> tests/generateParams}},{{/required}}{{/parametersWithDataType}}{{/isHelper}}{{/hasParams}}{{#requestOptions}}{{#queryParameters.parametersWithDataType}}{{clientPrefix}}.WithQueryParam("{{{key}}}", {{> tests/generateInnerParams}}),{{/queryParameters.parametersWithDataType}}{{#headers.parametersWithDataType}}{{clientPrefix}}.WithHeaderParam("{{{key}}}", {{> tests/generateInnerParams}}),{{/headers.parametersWithDataType}} {{#timeouts.read}} ,{{clientPrefix}}.WithReadTimeout({{.}} * time.Millisecond), {{/timeouts.read}} {{#timeouts.write}} ,{{clientPrefix}}.WithWriteTimeout({{.}} * time.Millisecond), {{/timeouts.write}} {{#timeouts.connect}} ,{{clientPrefix}}.WithConnectTimeout({{.}} * time.Millisecond), {{/timeouts.connect}} {{/requestOptions}})
5 changes: 2 additions & 3 deletions templates/java/tests/e2e/e2e.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import io.github.cdimascio.dotenv.Dotenv;
import java.util.*;
import java.time.Duration;
import org.junit.jupiter.api.*;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
import com.algolia.utils.TestHelpers;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class {{client}}RequestsTestsE2E {
Expand Down Expand Up @@ -45,7 +44,7 @@ class {{client}}RequestsTestsE2E {
{{returnType}} res = {{> tests/method}};
{{#response}}
{{#body}}
assertDoesNotThrow(() -> JSONAssert.assertEquals("{{#lambda.escapeQuotes}}{{{body}}}{{/lambda.escapeQuotes}}", json.writeValueAsString(res), JSONCompareMode.LENIENT));
assertDoesNotThrow(() -> TestHelpers.lenientJsonAssert("{{#lambda.escapeQuotes}}{{{body}}}{{/lambda.escapeQuotes}}", json.writeValueAsString(res)));
{{/body}}
{{/response}}
}
Expand Down
2 changes: 1 addition & 1 deletion templates/kotlin/builder_function.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/
public fun {{vendorExtensions.x-one-of-explicit-name}}(
{{#vars}}
{{{name}}}: {{> data_class_field_type}}{{^required}}? = null{{/required}},
{{{name}}}: {{> data_class_field_type}}{{#required}}{{#isNullable}}? = null{{/isNullable}}{{/required}}{{^required}}? = null{{/required}},
{{/vars}}
): {{classname}} = {{vendorExtensions.x-fully-qualified-classname}} (
{{#vars}}
Expand Down
2 changes: 1 addition & 1 deletion templates/kotlin/data_class_field.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
{{#deprecated}}
@Deprecated(message = "This property is deprecated.")
{{/deprecated}}
@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") {{#isInherited}}override {{/isInherited}}val {{{name}}}: {{> data_class_field_type}}{{^required}}? = null{{/required}}
@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") {{#isInherited}}override {{/isInherited}}val {{{name}}}: {{> data_class_field_type}}{{#required}}{{#isNullable}}? = null{{/isNullable}}{{/required}}{{^required}}? = null{{/required}}
2 changes: 1 addition & 1 deletion templates/kotlin/json_object_field.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
{{#deprecated}}
@Deprecated(message = "This property is deprecated.")
{{/deprecated}}
{{#isInherited}}override {{/isInherited}}val {{{name}}}: {{> data_class_field_type}}{{^required}}? = null{{/required}}
{{#isInherited}}override {{/isInherited}}val {{{name}}}: {{> data_class_field_type}}{{#required}}{{#isNullable}}? = null{{/isNullable}}{{/required}}{{^required}}? = null{{/required}}
4 changes: 1 addition & 3 deletions templates/kotlin/tests/e2e/e2e.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import com.algolia.client.model.{{import}}.*
import com.algolia.client.configuration.*
import com.algolia.client.transport.*
import com.algolia.utils.*
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
import io.ktor.http.*
import io.github.cdimascio.dotenv.Dotenv
import kotlinx.coroutines.test.*
Expand Down Expand Up @@ -44,7 +42,7 @@ class {{clientPrefix}}Test {
{{#response}}
{{#body}}
response = {
JSONAssert.assertEquals("{{#lambda.escapeQuotes}}{{{body}}}{{/lambda.escapeQuotes}}", Json.encodeToString(it), JSONCompareMode.LENIENT)
lenientJsonAssert("{{#lambda.escapeQuotes}}{{{body}}}{{/lambda.escapeQuotes}}", Json.encodeToString(it))
},
{{/body}}
{{/response}}
Expand Down
2 changes: 1 addition & 1 deletion templates/kotlin/tests/request_param.mustache
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{^hasAdditionalProperties}}{{#lambda.camelcase}}{{{key}}}{{/lambda.camelcase}} = {{> tests/param_value}},{{/hasAdditionalProperties}}{{#hasAdditionalProperties}}{{> tests/param_value}},{{/hasAdditionalProperties}}
{{^isNull}}{{^hasAdditionalProperties}}{{#lambda.camelcase}}{{{key}}}{{/lambda.camelcase}} = {{> tests/param_value}},{{/hasAdditionalProperties}}{{#hasAdditionalProperties}}{{> tests/param_value}},{{/hasAdditionalProperties}}{{/isNull}}{{#isNull}}{{#required}}{{^hasAdditionalProperties}}{{#lambda.camelcase}}{{{key}}}{{/lambda.camelcase}} = {{> tests/param_value}},{{/hasAdditionalProperties}}{{#hasAdditionalProperties}}{{> tests/param_value}},{{/hasAdditionalProperties}}{{/required}}{{/isNull}}
2 changes: 1 addition & 1 deletion templates/scala/tests/request_param.mustache
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{^hasAdditionalProperties}}{{#lambda.identifier}}{{key}}{{/lambda.identifier}} = {{> tests/param_optional}}{{/hasAdditionalProperties}}{{#hasAdditionalProperties}}{{> tests/param_value}}{{/hasAdditionalProperties}}
{{^hasAdditionalProperties}}{{#isNull}}{{^required}}{{#lambda.identifier}}{{key}}{{/lambda.identifier}} = None{{/required}}{{#required}}{{#lambda.identifier}}{{key}}{{/lambda.identifier}} = {{> tests/param_optional}}{{/required}}{{/isNull}}{{^isNull}}{{#lambda.identifier}}{{key}}{{/lambda.identifier}} = {{> tests/param_optional}}{{/isNull}}{{/hasAdditionalProperties}}{{#hasAdditionalProperties}}{{> tests/param_value}}{{/hasAdditionalProperties}}
2 changes: 1 addition & 1 deletion templates/swift/tests/paramValue.mustache
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{#isVerbatim}}{{{value}}}{{/isVerbatim}}{{#isObject}}{{objectName}}({{^hasAdditionalProperties}}{{#value}}{{> tests/generateParams}}{{^-last}}, {{/-last}}{{/value}}{{/hasAdditionalProperties}}{{#hasAdditionalProperties}}from: [{{#value}}"{{key}}": AnyCodable({{> tests/paramValue }}){{^-last}}, {{/-last}}{{/value}}]{{/hasAdditionalProperties}}){{/isObject}}{{#isString}}{{#isAnyType}}AnyCodable({{/isAnyType}}"{{#lambda.escapeQuotes}}{{{value}}}{{/lambda.escapeQuotes}}"{{#isAnyType}}){{/isAnyType}}{{/isString}}{{#isNumber}}{{#isLong}}Int64({{/isLong}}{{{value}}}{{#isLong}}){{/isLong}}{{/isNumber}}{{#isBoolean}}{{{value}}}{{/isBoolean}}{{#isEnum}}{{objectName}}.{{#lambda.identifier}}{{#lambda.camelcase}}{{value}}{{/lambda.camelcase}}{{/lambda.identifier}}{{/isEnum}}{{#isArray}}[{{#value}}{{> tests/generateParams}}{{^-last}}, {{/-last}}{{/value}}]{{/isArray}}{{#isMap}}Map[{{{.}}}]{{/isMap}}{{#isFreeFormObject}}{{#isAnyType}}[{{#value}}{{#entrySet}}"{{key}}": "{{{value}}}"{{/entrySet}}{{/value}}]{{/isAnyType}}{{^isAnyType}}{{^value}}[String: AnyCodable](){{/value}}{{#value}}{{#-first}}[{{/-first}}{{> tests/generateParams}}{{^-last}}, {{/-last}}{{#-last}}]{{/-last}}{{/value}}{{/isAnyType}}{{/isFreeFormObject}}{{#isNull}}{{#inClientTest}}TestNull{{{objectName}}}(){{/inClientTest}}{{/isNull}}
{{#isVerbatim}}{{{value}}}{{/isVerbatim}}{{#isObject}}{{objectName}}({{^hasAdditionalProperties}}{{#value}}{{> tests/generateParams}}{{^-last}}, {{/-last}}{{/value}}{{/hasAdditionalProperties}}{{#hasAdditionalProperties}}from: [{{#value}}"{{key}}": AnyCodable({{> tests/paramValue }}){{^-last}}, {{/-last}}{{/value}}]{{/hasAdditionalProperties}}){{/isObject}}{{#isString}}{{#isAnyType}}AnyCodable({{/isAnyType}}"{{#lambda.escapeQuotes}}{{{value}}}{{/lambda.escapeQuotes}}"{{#isAnyType}}){{/isAnyType}}{{/isString}}{{#isNumber}}{{#isLong}}Int64({{/isLong}}{{{value}}}{{#isLong}}){{/isLong}}{{/isNumber}}{{#isBoolean}}{{{value}}}{{/isBoolean}}{{#isEnum}}{{objectName}}.{{#lambda.identifier}}{{#lambda.camelcase}}{{value}}{{/lambda.camelcase}}{{/lambda.identifier}}{{/isEnum}}{{#isArray}}[{{#value}}{{> tests/generateParams}}{{^-last}}, {{/-last}}{{/value}}]{{/isArray}}{{#isMap}}Map[{{{.}}}]{{/isMap}}{{#isFreeFormObject}}{{#isAnyType}}[{{#value}}{{#entrySet}}"{{key}}": "{{{value}}}"{{/entrySet}}{{/value}}]{{/isAnyType}}{{^isAnyType}}{{^value}}[String: AnyCodable](){{/value}}{{#value}}{{#-first}}[{{/-first}}{{> tests/generateParams}}{{^-last}}, {{/-last}}{{#-last}}]{{/-last}}{{/value}}{{/isAnyType}}{{/isFreeFormObject}}{{#isNull}}{{#inClientTest}}TestNull{{{objectName}}}(){{/inClientTest}}{{^inClientTest}}nil{{/inClientTest}}{{/isNull}}
54 changes: 54 additions & 0 deletions tests/output/csharp/src/Utils/TestHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Quibble.Xunit;

namespace Algolia.Search.Tests.Utils;

public static class TestHelpers
{
/// <summary>
/// Asserts that the serialized response contains at least the expected JSON structure.
/// Extra keys in objects and extra elements in arrays (beyond expected indices) are ignored.
/// Mirrors the union-based e2e assertion used by JS, Python, Ruby, PHP, Go, and Swift clients.
/// </summary>
public static void LenientJsonAssert(string expected, string actual)
{
var expectedNode = JsonNode.Parse(expected);
var actualNode = JsonNode.Parse(actual);
var unionNode = Union(expectedNode, actualNode);
var unionJson = JsonSerializer.Serialize(unionNode);
JsonAssert.EqualOverrideDefault(expected, unionJson, new JsonDiffConfig(true));
}

/// <summary>
/// Recursively intersects the structure of <paramref name="expected"/> with the values of
/// <paramref name="received"/>. Only keys/indices present in expected are kept.
/// </summary>
private static JsonNode Union(JsonNode expected, JsonNode received)
{
if (expected is JsonObject expectedObj && received is JsonObject receivedObj)
{
var result = new JsonObject();
foreach (var prop in expectedObj)
{
if (receivedObj[prop.Key] != null)
{
result[prop.Key] = Union(prop.Value, receivedObj[prop.Key]);
}
}
return result;
}

if (expected is JsonArray expectedArr && received is JsonArray receivedArr)
{
var result = new JsonArray();
for (int i = 0; i < expectedArr.Count && i < receivedArr.Count; i++)
{
result.Add(Union(expectedArr[i], receivedArr[i]));
}
return result;
}

return received.DeepClone();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.algolia.utils;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;

public class TestHelpers {

private static final ObjectMapper mapper = new ObjectMapper();

/**
* Asserts that the serialized response contains at least the expected JSON structure. Extra keys
* in objects and extra elements in arrays (beyond expected indices) are ignored. Mirrors the
* union-based e2e assertion used by JS, Python, Ruby, PHP, Go, Swift, and C# clients.
*/
public static void lenientJsonAssert(String expected, String actual) throws Exception {
JsonNode expectedNode = mapper.readTree(expected);
JsonNode actualNode = mapper.readTree(actual);
JsonNode unionNode = union(expectedNode, actualNode);
String unionJson = mapper.writeValueAsString(unionNode);
JSONAssert.assertEquals(expected, unionJson, JSONCompareMode.LENIENT);
}

/**
* Recursively intersects the structure of {@code expected} with the values of {@code received}.
* Only keys/indices present in expected are kept.
*/
private static JsonNode union(JsonNode expected, JsonNode received) {
if (expected.isObject() && received.isObject()) {
ObjectNode result = mapper.createObjectNode();
expected
.fieldNames()
.forEachRemaining(key -> {
if (received.has(key)) {
result.set(key, union(expected.get(key), received.get(key)));
}
});
return result;
}

if (expected.isArray() && received.isArray()) {
ArrayNode result = mapper.createArrayNode();
for (int i = 0; i < expected.size() && i < received.size(); i++) {
result.add(union(expected.get(i), received.get(i)));
}
return result;
}

return received.deepCopy();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.algolia.utils

import kotlinx.serialization.json.*
import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode

/**
* Asserts that the serialized response contains at least the expected JSON structure. Extra keys in
* objects and extra elements in arrays (beyond expected indices) are ignored. Mirrors the
* union-based e2e assertion used by JS, Python, Ruby, PHP, Go, Swift, and C# clients.
*/
public fun lenientJsonAssert(expected: String, actual: String) {
val expectedNode = Json.parseToJsonElement(expected)
val actualNode = Json.parseToJsonElement(actual)
val unionNode = union(expectedNode, actualNode)
val unionJson = Json.encodeToString(JsonElement.serializer(), unionNode)
JSONAssert.assertEquals(expected, unionJson, JSONCompareMode.LENIENT)
}

/**
* Recursively intersects the structure of [expected] with the values of [received]. Only
* keys/indices present in expected are kept.
*/
private fun union(expected: JsonElement, received: JsonElement): JsonElement {
if (expected is JsonObject && received is JsonObject) {
return buildJsonObject {
for ((key, value) in expected) {
if (key in received) {
put(key, union(value, received[key]!!))
}
}
}
}

if (expected is JsonArray && received is JsonArray) {
return buildJsonArray {
for (i in expected.indices) {
if (i < received.size) {
add(union(expected[i], received[i]))
}
}
}
}

return received
}
Loading