From f20130f87eb16fce6ce76d031561ecec94197523 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 13 Oct 2026 11:58:53 +0100 Subject: [PATCH 1/3] fix: default missing required collections when filling response defaults The spec gives a model collection no default, and models only filled required fields that carry one, so a response that omitted a required collection left it undefined (JS) or failed to decode (every other SDK). An empty list is the one value a collection's type always admits, so required collections now fill with it; nested models stay absent. --- mock-server/app/http.php | 9 ++++ src/SDK/Language.php | 14 +++++++ src/SDK/Language/Dart.php | 2 +- src/SDK/Language/DotNet.php | 2 +- src/SDK/Language/JS.php | 5 ++- src/SDK/Language/Kotlin.php | 4 +- src/SDK/Language/Ruby.php | 2 +- src/SDK/Language/Rust.php | 6 +-- src/SDK/Language/Swift.php | 2 +- src/SDK/SDK.php | 1 + templates/dart/lib/src/models/model.dart.twig | 2 +- templates/dotnet/Package/Models/Model.cs.twig | 2 +- templates/php/src/Models/Model.php.twig | 6 +-- templates/python/package/models/model.py.twig | 4 +- .../ruby/lib/container/models/model.rb.twig | 2 +- templates/rust/src/models/model.rs.twig | 4 +- templates/rust/tests/tests.rs | 1 + .../swift/Sources/Models/Model.swift.twig | 4 +- tests/e2e/Base.php | 1 + tests/e2e/languages/android/Tests.kt | 1 + tests/e2e/languages/apple/Tests.swift | 1 + tests/e2e/languages/dart/tests.dart | 1 + tests/e2e/languages/deno/tests.ts | 1 + tests/e2e/languages/dotnet/Tests.cs | 1 + tests/e2e/languages/flutter/tests.dart | 1 + tests/e2e/languages/go-v2/tests.go | 6 +++ tests/e2e/languages/go/tests.go | 6 +++ tests/e2e/languages/kotlin/Tests.kt | 1 + tests/e2e/languages/node/test.js | 1 + tests/e2e/languages/php/test.php | 1 + tests/e2e/languages/python/tests.py | 1 + tests/e2e/languages/react-native/browser.js | 1 + tests/e2e/languages/ruby/tests.rb | 1 + tests/e2e/languages/swift/Tests.swift | 1 + tests/e2e/languages/unity/Tests.cs | 1 + tests/e2e/languages/web/index.html | 1 + tests/e2e/languages/web/node.js | 1 + tests/generation/GenerationTest.php | 5 ++- tests/resources/spec-openapi3.json | 42 +++++++++++++++++++ 39 files changed, 124 insertions(+), 24 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 1a7d67fccb..b0c1e8a9cd 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -859,6 +859,15 @@ ]); }); +App::get('/v1/mock/tests/general/presets/empty') + ->desc('List Empty Presets') + ->groups(['mock']) + ->label('scope', 'public') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->json(['total' => 0]); + }); + App::get('/v1/mock/tests/compound') ->desc('Get compound union') ->groups(['mock']) diff --git a/src/SDK/Language.php b/src/SDK/Language.php index e1075a967a..a384eadc41 100644 --- a/src/SDK/Language.php +++ b/src/SDK/Language.php @@ -409,6 +409,20 @@ protected function getSchema(Schema|Parameter $value): Schema return $value instanceof Parameter ? ($value->schema ?? new AnySchema()) : $value; } + /** + * The value a model fills a required field with when a response omits it. A collection the + * spec gives no default is filled empty, the only value its type always admits; a nested model + * stays absent, since an empty object may not satisfy the fields it requires. + */ + public function getModelDefault(Schema $property, bool $required): mixed + { + if (!$required) { + return null; + } + + return $property->default ?? ($property instanceof ArraySchema ? [] : null); + } + protected function getSchemaType(Schema|Parameter $value): string { $schema = $this->getSchema($value); diff --git a/src/SDK/Language/Dart.php b/src/SDK/Language/Dart.php index e1664ba34d..9a3bba50ab 100644 --- a/src/SDK/Language/Dart.php +++ b/src/SDK/Language/Dart.php @@ -547,7 +547,7 @@ protected function getDartLiteral(mixed $value): string protected function getDartDefault(Schema $property): string { - $value = $this->getSchema($property)->default; + $value = $this->getModelDefault($this->getSchema($property), true); if (is_array($value) && $this->getSchemaType($property) === self::TYPE_OBJECT) { return $this->getDartMap($value); diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index 1c45b44db9..50681e8234 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -600,7 +600,7 @@ protected function getToMapExpression(Schema $property, string $resolvedName, Sp protected function formatCSharpDefault(Schema $property): string { - $value = $this->getSchema($property)->default; + $value = $this->getModelDefault($this->getSchema($property), true); if (is_array($value) && $this->getSchemaType($property) === self::TYPE_OBJECT) { return $this->formatCSharpDictionary($value); diff --git a/src/SDK/Language/JS.php b/src/SDK/Language/JS.php index f6c2628ada..27c77c601b 100644 --- a/src/SDK/Language/JS.php +++ b/src/SDK/Language/JS.php @@ -529,8 +529,9 @@ protected function getResponseDefaults(Operation $method, Specification $spec, i } $models[$name] = ['defaults' => [], 'properties' => []]; foreach ($model->properties as $key => $property) { - if ($property->default !== null && \in_array($key, $model->required, true)) { - $models[$name]['defaults'][$key] = $this->getLiteral($property->default); + $default = $this->getModelDefault($property, \in_array($key, $model->required, true)); + if ($default !== null) { + $models[$name]['defaults'][$key] = $this->getLiteral($default); } $shape = $this->getDefaultsShape($property, $spec); if ($shape !== null) { diff --git a/src/SDK/Language/Kotlin.php b/src/SDK/Language/Kotlin.php index f6b6b91646..19a7198afe 100644 --- a/src/SDK/Language/Kotlin.php +++ b/src/SDK/Language/Kotlin.php @@ -624,8 +624,8 @@ protected function getPropertyAssignment(Schema $property, Specification $spec): } $mapKey = 'map["' . \str_replace('$', '\\$', $propertyName) . '"]'; - $default = $this->getSchema($property)->default; - if ($required && $default !== null) { + $default = $this->getModelDefault($property, $required); + if ($default !== null) { $mapKey = '(if (map.containsKey(' . $this->getStringLiteral($propertyName, 'kotlin') . ')) ' . $mapKey . ' else ' . $this->getValueLiteral($default, $property, $spec) . ')'; } diff --git a/src/SDK/Language/Ruby.php b/src/SDK/Language/Ruby.php index 250e50e719..aa9493d7c9 100644 --- a/src/SDK/Language/Ruby.php +++ b/src/SDK/Language/Ruby.php @@ -341,7 +341,7 @@ protected function jsonToHash(array $data, int $indent = 0): string protected function formatRubyDefault(Schema|Parameter $property): string { - $default = $this->getSchema($property)->default; + $default = $this->getModelDefault($this->getSchema($property), true); if (is_array($default) && $this->getSchemaType($property) === self::TYPE_OBJECT) { return $this->formatRubyHash($default); diff --git a/src/SDK/Language/Rust.php b/src/SDK/Language/Rust.php index c46ea4aaad..4d238ef689 100644 --- a/src/SDK/Language/Rust.php +++ b/src/SDK/Language/Rust.php @@ -590,7 +590,7 @@ function ($value, $indent = 0): string { }, ["is_safe" => ["html"]]), new TwigFilter("inputType", fn(Schema|Parameter $property, ?Specification $spec = null, string $generic = "serde_json::Value"): string => $this->getInputType($property, $spec)), new TwigFilter("paramValue", fn(Schema|Parameter $property, string $paramName, ?Specification $spec = null): string => $this->getParamValue($property, $paramName, $spec), ["is_safe" => ["html"]]), - new TwigFilter("rustDefault", fn(Schema $property, ?Specification $spec = null): string => $this->getModelDefault($property, $spec), ["is_safe" => ["html"]]), + new TwigFilter("rustDefault", fn(Schema $property, ?Specification $spec = null): string => $this->getRustDefault($property, $spec), ["is_safe" => ["html"]]), new TwigFilter("rustType", fn($value): string|array => str_replace(['<', '>'], ['<', '>'], $value), ["is_safe" => ["html"]]), new TwigFilter("rustCrateName", fn($value): string|array => str_replace('-', '_', $value)), new TwigFilter("stripProtocol", fn($value): string|array => str_replace(['https://', 'http://'], '', $value)), @@ -864,9 +864,9 @@ protected function rustfmtHeaderInsert(int $indent, string $key, string $valueEx return $this->rustfmtCall($indent, 'next.config.headers.insert', $args); } - protected function getModelDefault(Schema $property, ?Specification $spec = null): string + protected function getRustDefault(Schema $property, ?Specification $spec = null): string { - $value = $this->getSchema($property)->default; + $value = $this->getModelDefault($this->getSchema($property), true); $json = is_array($value) && $this->getSchemaType($property) === self::TYPE_OBJECT ? $this->getRustJsonMap($value) : $this->getRustJson($value); diff --git a/src/SDK/Language/Swift.php b/src/SDK/Language/Swift.php index c7051686e0..bc0aba1b25 100644 --- a/src/SDK/Language/Swift.php +++ b/src/SDK/Language/Swift.php @@ -649,7 +649,7 @@ public function getFilters(): array }), new TwigFilter('modelToMapValue', fn(Schema $property, string $name, bool $required): string => $this->getModelToMapValue($property, $name, $required), ['is_safe' => ['html']]), new TwigFilter('swiftLiteral', fn(mixed $value): string => $this->swiftLiteral($value, 0), ['is_safe' => ['html']]), - new TwigFilter('swiftErasedDefault', fn(Schema $property): string => $this->erasedLiteral($property->default, $property instanceof ArraySchema ? false : ($property->default === [] ? true : null), $property instanceof NumberSchema), ['is_safe' => ['html']]), + new TwigFilter('swiftErasedDefault', fn(Schema $property): string => $this->erasedLiteral($this->getModelDefault($property, true), $property instanceof ArraySchema ? false : ($property->default === [] ? true : null), $property instanceof NumberSchema), ['is_safe' => ['html']]), new TwigFilter('swiftDoc', fn(string $value, int $width = 75, string $prefix = ' ///'): string => $this->swiftDoc($value, $width, $prefix), ['is_safe' => ['html']]), ]; } diff --git a/src/SDK/SDK.php b/src/SDK/SDK.php index 9674c70e4f..33fbdfa755 100644 --- a/src/SDK/SDK.php +++ b/src/SDK/SDK.php @@ -168,6 +168,7 @@ public function __construct(protected Language $language, protected Specificatio $this->twig->addFilter(new TwigFilter('schemaRequired', fn(Schema|Parameter $value): bool => $value instanceof Parameter ? $value->required : isset($this->requiredSchemas[\spl_object_id($value)]))); $this->twig->addFilter(new TwigFilter('schemaExample', fn(Schema|Parameter $value): mixed => $this->getSchema($value)->example)); $this->twig->addFilter(new TwigFilter('schemaDefault', fn(Schema|Parameter $value): mixed => $this->getSchema($value)->default)); + $this->twig->addFilter(new TwigFilter('modelDefault', fn(Schema $value): mixed => $this->language->getModelDefault($value, isset($this->requiredSchemas[\spl_object_id($value)])))); $this->twig->addFilter(new TwigFilter('enumName', fn(Schema|Parameter $value): string => $this->getEnumName($value))); $this->twig->addFilter(new TwigFilter('enumKeys', fn(Schema|Parameter $value): array => $this->language->resolveEnumKeys($value))); $this->twig->addFilter(new TwigFilter('operations', fn(Tag $tag): array => $this->getFilteredMethods($this->getMethods($tag->name), $tag->name))); diff --git a/templates/dart/lib/src/models/model.dart.twig b/templates/dart/lib/src/models/model.dart.twig index c243ce7f08..8b3a491b35 100644 --- a/templates/dart/lib/src/models/model.dart.twig +++ b/templates/dart/lib/src/models/model.dart.twig @@ -30,7 +30,7 @@ class {{ definitionName | caseUcfirst | overrideIdentifier }} implements Model { factory {{ definitionName | caseUcfirst | overrideIdentifier }}.fromMap( Map map, ) { -{% set defaultedProperties = definition.properties | filter((property) => (property | schemaRequired) and (property | schemaDefault) is not null) %} +{% set defaultedProperties = definition.properties | filter((property) => (property | modelDefault) is not null) %} {% if defaultedProperties is not empty %} map = { {% for property in defaultedProperties %} diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index 23b482399d..e39d1fc240 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -40,7 +40,7 @@ namespace {{ spec.info.title | caseUcfirst }}.Models public static {{ definitionName | caseUcfirst | overrideIdentifier }} From(Dictionary map) => new {{ definitionName | caseUcfirst | overrideIdentifier }}( {%~ for property in definition.properties %} - {%- set source = (property | schemaDefault) is not null + {%- set source = (property | modelDefault) is not null ? '(map.ContainsKey("' ~ (property | schemaName) ~ '") ? map["' ~ (property | schemaName) ~ '"] : ' ~ (property | csharpDefault) ~ ')' : 'map["' ~ (property | schemaName) ~ '"]' %} {{ (property | schemaName) | caseCamel | escapeKeyword | removeDollarSign }}:{{' '}} diff --git a/templates/php/src/Models/Model.php.twig b/templates/php/src/Models/Model.php.twig index 9602460740..04b7b62be7 100644 --- a/templates/php/src/Models/Model.php.twig +++ b/templates/php/src/Models/Model.php.twig @@ -6,8 +6,8 @@ namespace {{ namespace | caseNamespace }}\Models; {% set requiredProperties = definition.properties | filter((property) => (property | schemaRequired)) %} {% set optionalProperties = definition.properties | filter((property) => not (property | schemaRequired)) %} -{% set defaultedProperties = requiredProperties | filter((property) => (property | schemaDefault) is not null) %} -{% set checkedProperties = requiredProperties | filter((property) => (property | schemaDefault) is null) %} +{% set defaultedProperties = requiredProperties | filter((property) => (property | modelDefault) is not null) %} +{% set checkedProperties = requiredProperties | filter((property) => (property | modelDefault) is null) %} {% set enumImports = [] %} {% for property in definition.properties %} {% if (property | enumValues) is not empty %} @@ -83,7 +83,7 @@ readonly class {{ definitionName | caseUcfirst | overrideIdentifier }} {% if defaultedProperties is not empty %} $data += [ {% for property in defaultedProperties %} - '{{ (property | schemaName) }}' => {{ property | schemaDefault | phpLiteral }}, + '{{ (property | schemaName) }}' => {{ property | modelDefault | phpLiteral }}, {% endfor %} ]; {% if checkedProperties is not empty %} diff --git a/templates/python/package/models/model.py.twig b/templates/python/package/models/model.py.twig index 8f59a10097..1115670068 100644 --- a/templates/python/package/models/model.py.twig +++ b/templates/python/package/models/model.py.twig @@ -1,6 +1,6 @@ {% set hasGenericProperty = definition.properties|hasGenericTypeProperty(spec) %} {% set isGeneric = definition.additionalProperties or hasGenericProperty %} -{% set defaulted = definition.properties | filter(property => (property | schemaRequired) and (property | schemaDefault) is not null) %} +{% set defaulted = definition.properties | filter(property => (property | modelDefault) is not null) %} from typing import Any, Dict, List, Optional, Union, cast{% if isGeneric %}, Generic, TypeVar, Type{% endif %} from pydantic import Field, PrivateAttr{{ definition.additionalProperties ? ', TypeAdapter' : '' }}{{ defaulted is not empty ? ', model_validator' : '' }}{{ definition.additionalProperties ? ', model_serializer' : '' }} @@ -82,7 +82,7 @@ class {{ definitionName | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Generi {% for property in defaulted %} {% set fieldName = property | getModelFieldName(definition.properties) %} if '{{ property | schemaName }}' not in data{% if fieldName != (property | schemaName) %} and '{{ fieldName }}' not in data{% endif %}: - data['{{ property | schemaName }}'] = {{ property | schemaDefault | pythonLiteral | raw }} + data['{{ property | schemaName }}'] = {{ property | modelDefault | pythonLiteral | raw }} {% endfor %} return data {% endif %} diff --git a/templates/ruby/lib/container/models/model.rb.twig b/templates/ruby/lib/container/models/model.rb.twig index e0b9a75124..e188a183a0 100644 --- a/templates/ruby/lib/container/models/model.rb.twig +++ b/templates/ruby/lib/container/models/model.rb.twig @@ -37,7 +37,7 @@ module {{ spec.info.title | caseUcfirst }} end def self.from(map:) -{% set defaults = definition.properties | filter(p => (p | schemaRequired) and (p | schemaDefault) is not null) %} +{% set defaults = definition.properties | filter(p => (p | modelDefault) is not null) %} {% if defaults | length > 0 %} map = { {% for property in defaults %}"{{ property | schemaName }}" => {{ property | rubyDefault }}{% if not loop.last %}, {% endif %}{% endfor %} }.merge(map) {% endif %} diff --git a/templates/rust/src/models/model.rs.twig b/templates/rust/src/models/model.rs.twig index 20b687dfd8..ef3cacf718 100644 --- a/templates/rust/src/models/model.rs.twig +++ b/templates/rust/src/models/model.rs.twig @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; {% endif %} {% set additionalFieldName = definition.properties.data is defined ? 'additional_data' : 'data' %} -{% set defaultedProperties = definition.properties | filter((property) => (property | schemaRequired) and (property | schemaDefault) is not null) %} +{% set defaultedProperties = definition.properties | filter((property) => (property | modelDefault) is not null) %} {{ definition.description | rustdocComment }} #[derive(Debug, Clone, Serialize, Deserialize)] @@ -17,7 +17,7 @@ pub struct {{ definitionName | caseUcfirst | overrideIdentifier }} { {% set fieldName = fieldName | overrideIdentifier %} {{ property.description | rustdocComment(4) }} #[serde(rename = "{{ (property | schemaName) }}")] -{% if (property | schemaRequired) and (property | schemaDefault) is not null %} +{% if (property | modelDefault) is not null %} #[serde(default = "default_{{ fieldName | replace({'r#': ''}) }}")] {% endif %} {% if (property | schemaRequired) and not (property | schemaNullable) %} diff --git a/templates/rust/tests/tests.rs b/templates/rust/tests/tests.rs index 53fb61633c..46b59961f1 100644 --- a/templates/rust/tests/tests.rs +++ b/templates/rust/tests/tests.rs @@ -151,6 +151,7 @@ async fn test_general_service(client: &Client, string_in_array: &[String]) -> Re for item in general.list_presets().await?.presets { println!("preset: name={}, count={}, enabled={}", item.name, item.count, item.enabled); } + println!("empty presets: {}", general.list_empty_presets().await?.presets.len()); // Exception Responses match general.error400().await { diff --git a/templates/swift/Sources/Models/Model.swift.twig b/templates/swift/Sources/Models/Model.swift.twig index 793d18e6c9..8d3c13a804 100644 --- a/templates/swift/Sources/Models/Model.swift.twig +++ b/templates/swift/Sources/Models/Model.swift.twig @@ -50,7 +50,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) {%~ for property in definition.properties %} - {%~ set fallback = (property | schemaRequired) ? (property | schemaDefault) : null %} + {%~ set fallback = property | modelDefault %} {%~ if (property | schemaType) != 'array' and (property | enumValues) is not empty %} {%~ if (property | schemaRequired) %} self.{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }} = {{ property | propertyType(spec) | raw }}(rawValue: {% if fallback is not null %}try container.contains(.{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}) ? container.decode(String.self, forKey: .{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}) : {{ fallback | swiftLiteral }}{% else %}try container.decode(String.self, forKey: .{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}){% endif %})! @@ -107,7 +107,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { } public static func from(map: [String: Any]) -> {{ definitionName | caseUcfirst }} { - {%~ set defaults = definition.properties | filter(p => (p | schemaRequired) and (p | schemaDefault) is not null) %} + {%~ set defaults = definition.properties | filter(p => (p | modelDefault) is not null) %} {%~ if defaults | length > 0 %} let defaults: [String: Any] = [ {%~ for property in defaults %} diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index b9dcd38539..342dbf5875 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -94,6 +94,7 @@ abstract class Base extends TestCase 'preset: name=guest, count=25, enabled=false', 'preset: name=guest, count=25, enabled=false', 'preset: name=server, count=25, enabled=true', + 'empty presets: 0', ]; protected const PYTHON_MODEL_DEFAULT_RESPONSES = [ diff --git a/tests/e2e/languages/android/Tests.kt b/tests/e2e/languages/android/Tests.kt index 25e9cc1031..1fec028dd5 100644 --- a/tests/e2e/languages/android/Tests.kt +++ b/tests/e2e/languages/android/Tests.kt @@ -281,6 +281,7 @@ class ServiceTest { general.listPresets().presets.forEach { writeToFile("preset: name=${it.name}, count=${it.count}, enabled=${it.enabled}") } + writeToFile("empty presets: ${general.listEmptyPresets().presets.size}") try { general.error400() diff --git a/tests/e2e/languages/apple/Tests.swift b/tests/e2e/languages/apple/Tests.swift index 323c0a77fc..cb4db33736 100644 --- a/tests/e2e/languages/apple/Tests.swift +++ b/tests/e2e/languages/apple/Tests.swift @@ -248,6 +248,7 @@ class Tests: XCTestCase { for item in try await general.listPresets().presets { print("preset: name=\(item.name), count=\(item.count), enabled=\(item.enabled)") } + print("empty presets: \(try await general.listEmptyPresets().presets.count)") do { try await general.error400() diff --git a/tests/e2e/languages/dart/tests.dart b/tests/e2e/languages/dart/tests.dart index 078543d62f..31b2635639 100644 --- a/tests/e2e/languages/dart/tests.dart +++ b/tests/e2e/languages/dart/tests.dart @@ -153,6 +153,7 @@ void main() async { for (final item in presets.presets) { print('preset: name=${item.name}, count=${item.count}, enabled=${item.enabled}'); } + print('empty presets: ${(await general.listEmptyPresets()).presets.length}'); try { await general.error400(); diff --git a/tests/e2e/languages/deno/tests.ts b/tests/e2e/languages/deno/tests.ts index b63b12273f..d14f4e4c70 100644 --- a/tests/e2e/languages/deno/tests.ts +++ b/tests/e2e/languages/deno/tests.ts @@ -175,6 +175,7 @@ async function start() { for (const item of presets.presets) { console.log(`preset: name=${item.name}, count=${item.count}, enabled=${item.enabled}`); } + console.log(`empty presets: ${(await general.listEmptyPresets()).presets.length}`); try { response = await general.error400(); diff --git a/tests/e2e/languages/dotnet/Tests.cs b/tests/e2e/languages/dotnet/Tests.cs index 01d8fc5074..17c7173c56 100644 --- a/tests/e2e/languages/dotnet/Tests.cs +++ b/tests/e2e/languages/dotnet/Tests.cs @@ -144,6 +144,7 @@ public async Task Test1() { TestContext.WriteLine($"preset: name={item.Name}, count={item.Count}, enabled={item.Enabled.ToString().ToLowerInvariant()}"); } + TestContext.WriteLine($"empty presets: {(await general.ListEmptyPresets()).Presets.Count}"); try { diff --git a/tests/e2e/languages/flutter/tests.dart b/tests/e2e/languages/flutter/tests.dart index 74152951d1..255cc03081 100644 --- a/tests/e2e/languages/flutter/tests.dart +++ b/tests/e2e/languages/flutter/tests.dart @@ -225,6 +225,7 @@ void main() async { for (final item in presets.presets) { print('preset: name=${item.name}, count=${item.count}, enabled=${item.enabled}'); } + print('empty presets: ${(await general.listEmptyPresets()).presets.length}'); try { await general.error400(); diff --git a/tests/e2e/languages/go-v2/tests.go b/tests/e2e/languages/go-v2/tests.go index f124d03ce3..653acd0429 100644 --- a/tests/e2e/languages/go-v2/tests.go +++ b/tests/e2e/languages/go-v2/tests.go @@ -208,6 +208,12 @@ func testGeneralService(client client.Client, stringInArray []string) { fmt.Printf("preset: name=%s, count=%d, enabled=%t\n", item.Name, item.Count, item.Enabled) } } + emptyPresets, err := general.ListEmptyPresets() + if err != nil { + fmt.Printf("general.ListEmptyPresets => error %v\n", err) + } else { + fmt.Printf("empty presets: %d\n", len(emptyPresets.Presets)) + } // Exception Responses _, err = general.Error400() diff --git a/tests/e2e/languages/go/tests.go b/tests/e2e/languages/go/tests.go index 0213c3aa63..16a6bd2bf6 100644 --- a/tests/e2e/languages/go/tests.go +++ b/tests/e2e/languages/go/tests.go @@ -208,6 +208,12 @@ func testGeneralService(client client.Client, stringInArray []string) { fmt.Printf("preset: name=%s, count=%d, enabled=%t\n", item.Name, item.Count, item.Enabled) } } + emptyPresets, err := general.ListEmptyPresets() + if err != nil { + fmt.Printf("general.ListEmptyPresets => error %v\n", err) + } else { + fmt.Printf("empty presets: %d\n", len(emptyPresets.Presets)) + } // Exception Responses _, err = general.Error400() diff --git a/tests/e2e/languages/kotlin/Tests.kt b/tests/e2e/languages/kotlin/Tests.kt index 472e636e14..d5d755768c 100644 --- a/tests/e2e/languages/kotlin/Tests.kt +++ b/tests/e2e/languages/kotlin/Tests.kt @@ -183,6 +183,7 @@ class ServiceTest { general.listPresets().presets.forEach { writeToFile("preset: name=${it.name}, count=${it.count}, enabled=${it.enabled}") } + writeToFile("empty presets: ${general.listEmptyPresets().presets.size}") try { general.error400() diff --git a/tests/e2e/languages/node/test.js b/tests/e2e/languages/node/test.js index d01e0ecea0..da087b0853 100644 --- a/tests/e2e/languages/node/test.js +++ b/tests/e2e/languages/node/test.js @@ -284,6 +284,7 @@ async function start() { for (const item of presets.presets) { console.log(`preset: name=${item.name}, count=${item.count}, enabled=${item.enabled}`); } + console.log(`empty presets: ${(await general.listEmptyPresets()).presets.length}`); try { response = await general.error400(); diff --git a/tests/e2e/languages/php/test.php b/tests/e2e/languages/php/test.php index bcb37219cc..d66aeeb6d0 100644 --- a/tests/e2e/languages/php/test.php +++ b/tests/e2e/languages/php/test.php @@ -267,6 +267,7 @@ public function toArray(): array foreach ($general->listPresets()->presets as $item) { echo 'preset: name=' . $item->name . ', count=' . $item->count . ', enabled=' . ($item->enabled ? 'true' : 'false') . "\n"; } +echo 'empty presets: ' . count($general->listEmptyPresets()->presets) . "\n"; try { $response = $general->error400(); diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index 3858f5f054..2ba96cd573 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -130,6 +130,7 @@ print(f"preset: name={preset.name}, count={preset.count:g}, enabled={str(preset.enabled).lower()}") for item in general.list_presets().presets: print(f"preset: name={item.name}, count={item.count:g}, enabled={str(item.enabled).lower()}") +print(f"empty presets: {len(general.list_empty_presets().presets)}") print(f"preset tier: {preset.tier}") try: diff --git a/tests/e2e/languages/react-native/browser.js b/tests/e2e/languages/react-native/browser.js index 19dd4c4461..fa54a0de73 100644 --- a/tests/e2e/languages/react-native/browser.js +++ b/tests/e2e/languages/react-native/browser.js @@ -231,6 +231,7 @@ import { for (const item of presets.presets) { console.log(`preset: name=${item.name}, count=${item.count}, enabled=${item.enabled}`); } + console.log(`empty presets: ${(await general.listEmptyPresets()).presets.length}`); // Exception responses try { diff --git a/tests/e2e/languages/ruby/tests.rb b/tests/e2e/languages/ruby/tests.rb index 648248ce26..edd8cf6b29 100644 --- a/tests/e2e/languages/ruby/tests.rb +++ b/tests/e2e/languages/ruby/tests.rb @@ -144,6 +144,7 @@ general.list_presets().presets.each do |item| puts "preset: name=#{item.name}, count=#{item.count}, enabled=#{item.enabled}" end +puts "empty presets: #{general.list_empty_presets().presets.length}" begin general.error400() diff --git a/tests/e2e/languages/swift/Tests.swift b/tests/e2e/languages/swift/Tests.swift index f1c11eb315..4fd3bf40b1 100644 --- a/tests/e2e/languages/swift/Tests.swift +++ b/tests/e2e/languages/swift/Tests.swift @@ -189,6 +189,7 @@ class Tests: XCTestCase { for item in try await general.listPresets().presets { print("preset: name=\(item.name), count=\(item.count), enabled=\(item.enabled)") } + print("empty presets: \(try await general.listEmptyPresets().presets.count)") do { try await general.error400() diff --git a/tests/e2e/languages/unity/Tests.cs b/tests/e2e/languages/unity/Tests.cs index 74aff013ad..675362d040 100644 --- a/tests/e2e/languages/unity/Tests.cs +++ b/tests/e2e/languages/unity/Tests.cs @@ -236,6 +236,7 @@ private async Task RunAsyncTest() { LogResult($"preset: name={item.Name}, count={item.Count}, enabled={item.Enabled.ToString().ToLowerInvariant()}"); } + LogResult($"empty presets: {(await general.ListEmptyPresets()).Presets.Count}"); try { diff --git a/tests/e2e/languages/web/index.html b/tests/e2e/languages/web/index.html index 734f322050..efc42ba833 100644 --- a/tests/e2e/languages/web/index.html +++ b/tests/e2e/languages/web/index.html @@ -337,6 +337,7 @@ for (const item of presets.presets) { console.log(`preset: name=${item.name}, count=${item.count}, enabled=${item.enabled}`); } + console.log(`empty presets: ${(await general.listEmptyPresets()).presets.length}`); try { response = await general.error400(); diff --git a/tests/e2e/languages/web/node.js b/tests/e2e/languages/web/node.js index f5d9adf050..35fcdbbd8e 100644 --- a/tests/e2e/languages/web/node.js +++ b/tests/e2e/languages/web/node.js @@ -200,6 +200,7 @@ async function start() { for (const item of presets.presets) { console.log(`preset: name=${item.name}, count=${item.count}, enabled=${item.enabled}`); } + console.log(`empty presets: ${(await general.listEmptyPresets()).presets.length}`); try { response = await general.error400(); diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index 6744b3b969..6c1298a661 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -705,7 +705,7 @@ public function testWebNestedResponseDefaultsAreApplied(): void $service = (string) \file_get_contents($dir . '/src/services/general.ts'); $this->assertStringNotContainsString('...response', $service); - $this->assertSame(4, \substr_count($service, 'return request.then((response) => applyDefaults(response, defaults));')); + $this->assertSame(5, \substr_count($service, 'return request.then((response) => applyDefaults(response, defaults));')); $this->assertSame(2, \substr_count($service, <<<'TS' const defaults = { root: 'preset', @@ -729,6 +729,9 @@ public function testWebNestedResponseDefaultsAreApplied(): void root: 'presetList', models: { presetList: { + defaults: { + presets: [], + }, properties: { presets: { items: 'preset', diff --git a/tests/resources/spec-openapi3.json b/tests/resources/spec-openapi3.json index f3438d3f83..8cd7a59e0e 100644 --- a/tests/resources/spec-openapi3.json +++ b/tests/resources/spec-openapi3.json @@ -2749,6 +2749,48 @@ } } }, + "\/mock\/tests\/general\/presets\/empty": { + "get": { + "summary": "List Empty Presets", + "operationId": "generalListEmptyPresets", + "tags": [ + "general" + ], + "description": "Return a preset list that omits its required presets collection.", + "x-appwrite": { + "demo": "general\/list-empty-presets.md", + "rate-limit": 0, + "rate-time": 3600, + "rate-key": "url:{url},ip:{ip}", + "scope": "public", + "platforms": [ + "client", + "server" + ], + "packaging": false, + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [], + "Key": [], + "JWT": [] + } + ], + "responses": { + "200": { + "description": "Preset list", + "content": { + "application\/json": { + "schema": {"$ref": "#\/components\/schemas\/presetList"} + } + } + } + } + } + }, "\/mock\/tests\/union": { "get": { "summary": "Get Union", From 7967b9eb043645ccba5cb32a88d7371a6169cd94 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 13 Oct 2026 12:20:22 +0100 Subject: [PATCH 2/3] fix: keep generated SDKs lint-clean now list models fill defaults Required collections give most list models a defaults entry, which surfaced three generator gaps: the JS services imported applyDefaults for upload methods that never call it, Swift wrote a trailing comma in a one-entry literal and a long decode fallback, and the Node service tests keyed their mock data with an escaped dollar sign. --- templates/deno/src/services/service.ts.twig | 2 +- templates/node/src/services/template.ts.twig | 2 +- templates/node/test/services/service.test.js.twig | 2 +- templates/react-native/src/services/template.ts.twig | 2 +- templates/swift/Sources/Models/Model.swift.twig | 9 +++++++-- templates/web/src/services/template.ts.twig | 2 +- 6 files changed, 12 insertions(+), 7 deletions(-) diff --git a/templates/deno/src/services/service.ts.twig b/templates/deno/src/services/service.ts.twig index 20f06c7f0f..7194c85cf8 100644 --- a/templates/deno/src/services/service.ts.twig +++ b/templates/deno/src/services/service.ts.twig @@ -30,7 +30,7 @@ import { basename } from "https://deno.land/std@0.122.0/path/mod.ts"; import { Service } from '../service.ts'; {% set usesDefaults = false %} {% for method in (service | operations) %} -{% if (method | responseDefaults(spec, 8)) is not empty %}{% set usesDefaults = true %}{% endif %} +{% if 'multipart/form-data' not in (method | consumes) and (method | responseDefaults(spec, 8)) is not empty %}{% set usesDefaults = true %}{% endif %} {% endfor %} import { Payload, Client{% if usesDefaults %}, applyDefaults{% endif %} } from '../client.ts'; import { InputFile } from '../inputFile.ts'; diff --git a/templates/node/src/services/template.ts.twig b/templates/node/src/services/template.ts.twig index ffbd401888..8de360daee 100644 --- a/templates/node/src/services/template.ts.twig +++ b/templates/node/src/services/template.ts.twig @@ -3,7 +3,7 @@ {% set usesException = false %} {% set usesDefaults = false %} {% for method in (service | operations) %} -{% if (method | responseDefaults(spec, 8)) is not empty %}{% set usesDefaults = true %}{% endif %} +{% if 'multipart/form-data' not in (method | consumes) and (method | responseDefaults(spec, 8)) is not empty %}{% set usesDefaults = true %}{% endif %} {% if 'multipart/form-data' in (method | consumes) %}{% set usesUpload = true %}{% endif %} {% if 'Models.' in (method | getReturn(spec)) or 'Models.' in ((method | responseModel) | getGenerics(spec)) %}{% set usesModels = true %}{% endif %} {% for parameter in (method | parameters('all')) %} diff --git a/templates/node/test/services/service.test.js.twig b/templates/node/test/services/service.test.js.twig index 60cda47ba0..136d144713 100644 --- a/templates/node/test/services/service.test.js.twig +++ b/templates/node/test/services/service.test.js.twig @@ -35,7 +35,7 @@ describe('{{ service.name | caseUcfirst }}', () => { {%- if responseModelName %} {%- set dataBody %} {%- for definitionName, definition in definitions ~%}{%~ if definitionName == responseModelName -%}{%~ for property in definition.properties | filter((param) => param | schemaRequired) ~%} - {% set propValue %}{% if (property | schemaType) == 'object' %}{}{% elseif (property | schemaType) == 'array' %}[]{% elseif (property | schemaType) == 'string' %}{{ ((property | schemaExample) | escapeDollarSign) | jsString | raw }}{% elseif (property | schemaType) == 'boolean' %}true{% else %}{{ (property | schemaExample) }}{% endif %}{% endset %}{{ (((property | schemaName) | escapeDollarSign) | jsKey) | jsExampleEntry(propValue, '', 12) | raw }} + {% set propValue %}{% if (property | schemaType) == 'object' %}{}{% elseif (property | schemaType) == 'array' %}[]{% elseif (property | schemaType) == 'string' %}{{ ((property | schemaExample) | escapeDollarSign) | jsString | raw }}{% elseif (property | schemaType) == 'boolean' %}true{% else %}{{ (property | schemaExample) }}{% endif %}{% endset %}{{ ((property | schemaName) | jsKey) | jsExampleEntry(propValue, '', 12) | raw }} {%- endfor %}{% set break = true %}{%- else -%}{% set continue = true %}{%- endif -%}{%~ endfor %}{% endset %} const data = {% if dataBody | trim is empty %}{}{% else %}{{ '{' }}{{ dataBody | raw }} }{% endif %}; diff --git a/templates/react-native/src/services/template.ts.twig b/templates/react-native/src/services/template.ts.twig index 81922b2581..4eb092e602 100644 --- a/templates/react-native/src/services/template.ts.twig +++ b/templates/react-native/src/services/template.ts.twig @@ -4,7 +4,7 @@ {% set usesException = false %} {% set usesDefaults = false %} {% for method in (service | operations) %} -{% if (method | responseDefaults(spec, 8)) is not empty %}{% set usesDefaults = true %}{% endif %} +{% if 'multipart/form-data' not in (method | consumes) and (method | responseDefaults(spec, 8)) is not empty %}{% set usesDefaults = true %}{% endif %} {% if 'multipart/form-data' in (method | consumes) %}{% set usesUpload = true %}{% endif %} {% if 'multipart/form-data' in (method | consumes) and not (method | textResponse) %}{% set usesChunkedUpload = true %}{% endif %} {% if 'Models.' in (method | getReturn(spec)) or 'Models.' in ((method | responseModel) | getGenerics(spec)) %}{% set usesModels = true %}{% endif %} diff --git a/templates/swift/Sources/Models/Model.swift.twig b/templates/swift/Sources/Models/Model.swift.twig index 8d3c13a804..8ed847d0cb 100644 --- a/templates/swift/Sources/Models/Model.swift.twig +++ b/templates/swift/Sources/Models/Model.swift.twig @@ -73,7 +73,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { } {%~ endif %} {%~ else %} - self.{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }} = {% if fallback is not null %}try container.contains(.{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}) ? container.decode({{ property | propertyType(spec) | raw }}.self, forKey: .{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}) : {% if (property | schemaModel) and (property | schemaType) == 'array' %}({{ property | swiftErasedDefault }}).map { {{ (property | schemaModel) | caseUcfirst }}.from(map: $0 as! [String: Any]) }{% elseif (property | schemaModel) %}{{ (property | schemaModel) | caseUcfirst }}.from(map: {{ property | swiftErasedDefault }}){% elseif property | isAnyCodableArray(spec) %}({{ property | swiftErasedDefault }}).map { AnyCodable($0) }{% elseif property | isAnyCodableObject(spec) %}({{ property | swiftErasedDefault }}).mapValues { AnyCodable($0) }{% else %}{{ fallback | swiftLiteral }}{% endif %}{% else %}try container.decode{% if not (property | schemaRequired) %}IfPresent{% endif %}({{ property | propertyType(spec) | raw }}.self, forKey: .{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}){% endif %} + self.{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }} = {% if fallback is not null %}try container.contains(.{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}) ? container.decode({{ property | propertyType(spec) | raw }}.self, forKey: .{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}) : {% if (property | schemaType) == 'array' and fallback is empty %}[]{% elseif (property | schemaModel) and (property | schemaType) == 'array' %}({{ property | swiftErasedDefault }}).map { {{ (property | schemaModel) | caseUcfirst }}.from(map: $0 as! [String: Any]) }{% elseif (property | schemaModel) %}{{ (property | schemaModel) | caseUcfirst }}.from(map: {{ property | swiftErasedDefault }}){% elseif property | isAnyCodableArray(spec) %}({{ property | swiftErasedDefault }}).map { AnyCodable($0) }{% elseif property | isAnyCodableObject(spec) %}({{ property | swiftErasedDefault }}).mapValues { AnyCodable($0) }{% else %}{{ fallback | swiftLiteral }}{% endif %}{% else %}try container.decode{% if not (property | schemaRequired) %}IfPresent{% endif %}({{ property | propertyType(spec) | raw }}.self, forKey: .{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}){% endif %} {%~ endif %} {%~ endfor %} @@ -108,7 +108,12 @@ open class {{ definition | modelType(spec) | raw }}: Codable { public static func from(map: [String: Any]) -> {{ definitionName | caseUcfirst }} { {%~ set defaults = definition.properties | filter(p => (p | modelDefault) is not null) %} - {%~ if defaults | length > 0 %} + {%~ if defaults | length == 1 %} + {%~ set property = defaults | first %} + let defaults: [String: Any] = ["{{ property | schemaName }}": {{ property | swiftErasedDefault }}] + let map = defaults.merging(map) { _, new in new } + + {%~ elseif defaults | length > 1 %} let defaults: [String: Any] = [ {%~ for property in defaults %} "{{ property | schemaName }}": {{ property | swiftErasedDefault }}, diff --git a/templates/web/src/services/template.ts.twig b/templates/web/src/services/template.ts.twig index 24af0c5314..446aab9a55 100644 --- a/templates/web/src/services/template.ts.twig +++ b/templates/web/src/services/template.ts.twig @@ -3,7 +3,7 @@ {% set usesException = false %} {% set usesDefaults = false %} {% for method in (service | operations) %} -{% if (method | responseDefaults(spec, 8)) is not empty %}{% set usesDefaults = true %}{% endif %} +{% if 'multipart/form-data' not in (method | consumes) and (method | responseDefaults(spec, 8)) is not empty %}{% set usesDefaults = true %}{% endif %} {% if 'multipart/form-data' in (method | consumes) %}{% set usesUpload = true %}{% endif %} {% if 'Models.' in (method | getReturn(spec)) or 'Models.' in ((method | responseModel) | getGenerics(spec)) %}{% set usesModels = true %}{% endif %} {% for parameter in (method | parameters('all')) %} From 874fd13099e99c54fa2e4ee7f13a038de66d01b3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 13 Oct 2026 12:31:55 +0100 Subject: [PATCH 3/3] style(swift): keep the model decode line under the template length limit --- templates/swift/Sources/Models/Model.swift.twig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/swift/Sources/Models/Model.swift.twig b/templates/swift/Sources/Models/Model.swift.twig index 8ed847d0cb..1557b7edc9 100644 --- a/templates/swift/Sources/Models/Model.swift.twig +++ b/templates/swift/Sources/Models/Model.swift.twig @@ -73,7 +73,8 @@ open class {{ definition | modelType(spec) | raw }}: Codable { } {%~ endif %} {%~ else %} - self.{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }} = {% if fallback is not null %}try container.contains(.{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}) ? container.decode({{ property | propertyType(spec) | raw }}.self, forKey: .{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}) : {% if (property | schemaType) == 'array' and fallback is empty %}[]{% elseif (property | schemaModel) and (property | schemaType) == 'array' %}({{ property | swiftErasedDefault }}).map { {{ (property | schemaModel) | caseUcfirst }}.from(map: $0 as! [String: Any]) }{% elseif (property | schemaModel) %}{{ (property | schemaModel) | caseUcfirst }}.from(map: {{ property | swiftErasedDefault }}){% elseif property | isAnyCodableArray(spec) %}({{ property | swiftErasedDefault }}).map { AnyCodable($0) }{% elseif property | isAnyCodableObject(spec) %}({{ property | swiftErasedDefault }}).mapValues { AnyCodable($0) }{% else %}{{ fallback | swiftLiteral }}{% endif %}{% else %}try container.decode{% if not (property | schemaRequired) %}IfPresent{% endif %}({{ property | propertyType(spec) | raw }}.self, forKey: .{{ (property | schemaName) | escapeSwiftKeyword | removeDollarSign }}){% endif %} + {%~ set field = (property | schemaName) | escapeSwiftKeyword | removeDollarSign %} + self.{{ field }} = {% if fallback is not null %}try container.contains(.{{ field }}) ? container.decode({{ property | propertyType(spec) | raw }}.self, forKey: .{{ field }}) : {% if (property | schemaType) == 'array' and fallback is empty %}[]{% elseif (property | schemaModel) and (property | schemaType) == 'array' %}({{ property | swiftErasedDefault }}).map { {{ (property | schemaModel) | caseUcfirst }}.from(map: $0 as! [String: Any]) }{% elseif (property | schemaModel) %}{{ (property | schemaModel) | caseUcfirst }}.from(map: {{ property | swiftErasedDefault }}){% elseif property | isAnyCodableArray(spec) %}({{ property | swiftErasedDefault }}).map { AnyCodable($0) }{% elseif property | isAnyCodableObject(spec) %}({{ property | swiftErasedDefault }}).mapValues { AnyCodable($0) }{% else %}{{ fallback | swiftLiteral }}{% endif %}{% else %}try container.decode{% if not (property | schemaRequired) %}IfPresent{% endif %}({{ property | propertyType(spec) | raw }}.self, forKey: .{{ field }}){% endif %} {%~ endif %} {%~ endfor %}