From 6066d75184e1ebdeae4070e2afb07ff4ea7bf556 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 11 Sep 2026 14:59:45 +0530 Subject: [PATCH 1/7] fix(python): serialize nested list parameters by index --- templates/python/package/client.py.twig | 6 ++-- tests/e2e/Base.php | 6 ++++ tests/e2e/Python310Test.php | 1 + tests/e2e/Python311Test.php | 1 + tests/e2e/Python312Test.php | 1 + tests/e2e/Python313Test.php | 1 + tests/e2e/Python39Test.php | 1 + tests/e2e/languages/python/tests.py | 41 +++++++++++++++++++++++++ 8 files changed, 56 insertions(+), 2 deletions(-) diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index e9fb969571..2ed53c30dc 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -281,8 +281,10 @@ class Client: for key in data: value = data[key] if isinstance(data, dict) else key - finalKey = prefix + '[' + key + ']' if prefix else key - finalKey = prefix + '[' + str(i) + ']' if isinstance(data, list) else finalKey + if isinstance(data, list): + finalKey = prefix + '[' + str(i) + ']' + else: + finalKey = prefix + '[' + key + ']' if prefix else key i += 1 if isinstance(value, list) or isinstance(value, dict): diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index 05840af2d3..eb76ae73b7 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -45,6 +45,12 @@ abstract class Base extends TestCase 'GET:/v1/mock/tests/general/redirect/done:passed', ]; + protected const ARRAY_PARAMETER_RESPONSES = [ + 'Query parameter serialization:passed', + 'Nested query validation:400', + 'POST:/v1/mock/tests/general/documents:passed', + ]; + protected const PATH_PARAM_RESPONSES = [ 'GET:/v1/mock/tests/general/path/grant%2Fspecial%26id:passed', ]; diff --git a/tests/e2e/Python310Test.php b/tests/e2e/Python310Test.php index 8193b3129a..5bb39c66b6 100644 --- a/tests/e2e/Python310Test.php +++ b/tests/e2e/Python310Test.php @@ -37,6 +37,7 @@ final class Python310Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Python311Test.php b/tests/e2e/Python311Test.php index a16c23b8ae..200d77fa87 100644 --- a/tests/e2e/Python311Test.php +++ b/tests/e2e/Python311Test.php @@ -37,6 +37,7 @@ final class Python311Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Python312Test.php b/tests/e2e/Python312Test.php index faf0664318..a58d4caef7 100644 --- a/tests/e2e/Python312Test.php +++ b/tests/e2e/Python312Test.php @@ -37,6 +37,7 @@ final class Python312Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Python313Test.php b/tests/e2e/Python313Test.php index 1dc9fc15c3..13330b4c83 100644 --- a/tests/e2e/Python313Test.php +++ b/tests/e2e/Python313Test.php @@ -37,6 +37,7 @@ final class Python313Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Python39Test.php b/tests/e2e/Python39Test.php index c4265b7b67..3833a082bf 100644 --- a/tests/e2e/Python39Test.php +++ b/tests/e2e/Python39Test.php @@ -37,6 +37,7 @@ final class Python39Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index d4180a49b8..ad0142d7c7 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -12,6 +12,7 @@ from appwrite.enums.mock_type import MockType from appwrite.models.player import Player +import json import os.path client = Client() @@ -66,6 +67,46 @@ response = general.redirect() print(response['result']) +# Query strings keep their contents, while other scalar array values use the +# same string representation as scalar query parameters. +queries = [Query.equal('name', 'Zoë'), Query.limit(1)] +for values, expected in [ + (queries, queries), + ([0, 1.5, True, False], ['0', '1.5', 'true', 'false']), +]: + if json.loads(general.list_rows(values).result) != expected: + raise AssertionError('Query parameter values changed during serialization') +print('Query parameter serialization:passed') + +# Invalid nested queries reach API validation instead of crashing the SDK. +for values in [ + [{'method': 'limit', 'values': [1]}], + [['nested']], + [Query.limit(1), {'method': 'limit', 'values': [1]}], +]: + try: + general.list_rows(values) + raise AssertionError('Nested query was accepted') + except AppwriteException as error: + if error.code != 400 or 'queries' not in error.message: + raise +print('Nested query validation:400') + +# Object arrays are valid for other parameters, including multipart requests. +response = client.call( + 'post', + '/mock/tests/general/documents', + {'content-type': 'multipart/form-data', 'X-Appwrite-Project': 'console'}, + { + 'documents': [ + {'$id': 'first', 'values': [0, 1.5, True, False]}, + {'$id': 'second', 'nested': [{'name': 'Zoë', 'values': [[1, 2]]}]}, + ], + 'file': InputFile.from_bytes(b'fixture', 'fixture.txt', 'text/plain'), + }, +) +print(response['result']) + for id, plain in [('', '0'), ('0', '')]: try: general.validate_path(plain, id) From b3ad84aba218c2fcd61c98cd6b9aff80570feb1b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 11 Sep 2026 15:19:38 +0530 Subject: [PATCH 2/7] fix(python): validate string-list inputs from schemas --- mock-server/app/http.php | 12 +++- templates/python/base/params.twig | 7 ++ templates/python/package/service.py.twig | 14 ++++ tests/e2e/Base.php | 1 + tests/e2e/languages/python/tests.py | 82 ++++++++++++++++++++---- tests/resources/spec-openapi3.json | 9 +++ 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 48dbd1f390..3f7300688b 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -555,7 +555,7 @@ ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_MOCK) ->label('sdk.mock', true) - ->param('queries', [], new ArrayList(new Text(4096), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK.') + ->param('queries', [], new ArrayList(new Text(4096), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK.', true) ->inject('response') ->action(function (array $queries, UtopiaSwooleResponse $response) { $response->json(['result' => \json_encode($queries)]); @@ -655,7 +655,9 @@ ->label('sdk.response.model', Response::MODEL_MOCK) ->label('sdk.mock', true) ->param('documents', [], new ArrayList(new Assoc(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of document objects.') - ->action(function (array $documents) { + ->param('labels', null, new Nullable(new ArrayList(new Nullable(new Text(256)), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Optional labels, including null values.', true) + ->inject('response') + ->action(function (array $documents, ?array $labels, UtopiaSwooleResponse $response) { if ($documents === []) { throw new Exception(Exception::GENERAL_MOCK, 'Documents must not be empty'); } @@ -665,6 +667,12 @@ throw new Exception(Exception::GENERAL_MOCK, 'Each document must be an object with an $id'); } } + + $response->json([ + 'result' => 'POST:/v1/mock/tests/general/documents:passed', + 'documents' => $documents, + 'labels' => $labels, + ]); }); App::get('/v1/mock/tests/union') diff --git a/templates/python/base/params.twig b/templates/python/base/params.twig index e1bbc9e7b9..44c39df1c0 100644 --- a/templates/python/base/params.twig +++ b/templates/python/base/params.twig @@ -11,6 +11,13 @@ {% endif -%} {% endif -%} {% endfor -%} +{% for parameter in (method | parameters('all')) -%} +{% if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} +{% set paramName = parameter.name | escapeKeyword | caseSnake -%} +{% set validation %} self._validate_string_list('{{ paramName }}', {{ paramName }}{% if (parameter | arraySchema) | schemaNullable %}, nullable_items=True{% endif %}){% endset %} +{{ validation | formatCall | raw }} +{% endif -%} +{% endfor -%} {% for parameter in (method | parameters('path')) %} api_path = api_path.replace('{{ '{' }}{{ parameter.name }}{{ '}' }}', str(self._normalize_value({% if (parameter.extensions['x-sdk-source']|default('')) == 'security' %}self.client.get_config('{{ parameter.extensions['x-sdk-config'] | caseLower }}'){% else %}{{ parameter.name | escapeKeyword | caseSnake }}{% endif %}))) {% endfor -%} diff --git a/templates/python/package/service.py.twig b/templates/python/package/service.py.twig index abc7cf4be9..226ca5cab8 100644 --- a/templates/python/package/service.py.twig +++ b/templates/python/package/service.py.twig @@ -29,6 +29,20 @@ class Service: return value + def _validate_string_list(self, name: str, value: Any, nullable_items: bool = False) -> None: + """Validate item types; generated required checks handle missing lists.""" + if value is None: + return + + if not isinstance(value, list) or any( + not isinstance(self._normalize_value(item), str) and not (nullable_items and item is None) for item in value + ): + expected = 'strings or None' if nullable_items else 'strings' + raise AppwriteException( + f'Invalid parameter: "{name}" must be a list of {expected}.', + type='sdk_input_validation', + ) + def _parse_response(self, response: Any, model: Optional[Type[ModelType]] = None) -> Any: if model is None: return response diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index eb76ae73b7..c469794154 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -46,6 +46,7 @@ abstract class Base extends TestCase ]; protected const ARRAY_PARAMETER_RESPONSES = [ + 'String list validation:passed', 'Query parameter serialization:passed', 'Nested query validation:400', 'POST:/v1/mock/tests/general/documents:passed', diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index ad0142d7c7..7f3bd5f2da 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -15,6 +15,12 @@ import json import os.path + +class NoRequestClient(Client): + def call(self, *args, **kwargs): + raise AssertionError('Invalid input reached the request boundary') + + client = Client() foo = Foo(client) bar = Bar(client) @@ -67,44 +73,96 @@ response = general.redirect() print(response['result']) -# Query strings keep their contents, while other scalar array values use the -# same string representation as scalar query parameters. +# String-list validation follows the declared item type across query and body +# parameters, before reaching the request boundary. +invalid_general = General(NoRequestClient()) +invalid_foo = Foo(NoRequestClient()) +for values in ['query', {'method': 'limit'}, [1], [True], [None], [['nested']], ['valid', {'method': 'limit'}]]: + calls = [ + (invalid_general.list_rows, {'queries': values}, 'queries'), + (invalid_foo.get, {'x': 'string', 'y': 123, 'z': values}, 'z'), + (invalid_foo.post, {'x': 'string', 'y': 123, 'z': values}, 'z'), + ] + if values != [None]: + calls.append((invalid_general.create_documents, {'documents': [{'$id': 'one'}], 'labels': values}, 'labels')) + for method, arguments, name in calls: + try: + method(**arguments) + raise AssertionError('Invalid string list was accepted') + except AppwriteException as error: + if error.type != 'sdk_input_validation' or error.code != 0 or error.response is not None: + raise + if name not in error.message: + raise AssertionError('Validation error did not identify the parameter') + +for arguments, name in [({'x': None, 'y': 123, 'z': [1]}, 'x'), ({'x': 'string', 'y': 123, 'z': None}, 'z')]: + try: + invalid_foo.get(**arguments) + raise AssertionError('Missing required parameter was accepted') + except AppwriteException as error: + if error.message != f'Missing required parameter: "{name}"': + raise +print('String list validation:passed') + +# String contents are unchanged; this validates types, not JSON syntax or enum +# membership. Optional lists and normalized Enum values remain supported. queries = [Query.equal('name', 'Zoë'), Query.limit(1)] for values, expected in [ + (None, []), + ([], []), (queries, queries), - ([0, 1.5, True, False], ['0', '1.5', 'true', 'false']), + (['not JSON', MockType.FIRST], ['not JSON', 'first']), ]: if json.loads(general.list_rows(values).result) != expected: raise AssertionError('Query parameter values changed during serialization') + +# The generic request serializer also handles scalar array values independently +# of generated service parameter validation. +response = client.call('get', '/mock/tests/general/list-rows', params={'queries': [0, 1.5, True, False]}) +if json.loads(response['result']) != ['0', '1.5', 'true', 'false']: + raise AssertionError('Scalar array values changed during serialization') print('Query parameter serialization:passed') -# Invalid nested queries reach API validation instead of crashing the SDK. +# Raw requests still reach API validation for invalid nested queries. for values in [ [{'method': 'limit', 'values': [1]}], [['nested']], [Query.limit(1), {'method': 'limit', 'values': [1]}], ]: try: - general.list_rows(values) + client.call('get', '/mock/tests/general/list-rows', params={'queries': values}) raise AssertionError('Nested query was accepted') except AppwriteException as error: if error.code != 400 or 'queries' not in error.message: raise print('Nested query validation:400') -# Object arrays are valid for other parameters, including multipart requests. +# Generated object-array parameters preserve their contents, and nullable +# string-list items follow the schema independently of outer optionality. +documents = [ + {'$id': 'first', 'values': [0, 1.5, True, False]}, + {'$id': 'second', 'nested': [{'name': 'Zoë', 'values': [[1, 2]]}]}, +] +for labels, expected in [(None, None), ([], []), (['ready', None], ['ready', None]), ([MockType.FIRST], ['first'])]: + response = general.create_documents(documents, labels=labels) + if response.to_dict()['documents'] != documents or response.to_dict()['labels'] != expected: + raise AssertionError('Object arrays or nullable string-list items changed') + +# Multipart serialization preserves the nested structure at the API boundary. response = client.call( 'post', '/mock/tests/general/documents', {'content-type': 'multipart/form-data', 'X-Appwrite-Project': 'console'}, { - 'documents': [ - {'$id': 'first', 'values': [0, 1.5, True, False]}, - {'$id': 'second', 'nested': [{'name': 'Zoë', 'values': [[1, 2]]}]}, - ], + 'documents': documents, 'file': InputFile.from_bytes(b'fixture', 'fixture.txt', 'text/plain'), }, ) +if response['documents'] != [ + {'$id': 'first', 'values': ['0', '1.5', 'true', 'false']}, + {'$id': 'second', 'nested': [{'name': 'Zoë', 'values': [['1', '2']]}]}, +]: + raise AssertionError('Multipart document values or nesting changed') print(response['result']) for id, plain in [('', '0'), ('0', '')]: @@ -139,8 +197,8 @@ print(response.result) response = general.create_players([ - {'id': 'player1', 'name': 'John Doe', 'score': 100}, - {'id': 'player2', 'name': 'Jane Doe', 'score': 200} + Player(id='player1', name='John Doe', score=100), + Player(id='player2', name='Jane Doe', score=200), ]) print(response.result) diff --git a/tests/resources/spec-openapi3.json b/tests/resources/spec-openapi3.json index 057f47bfe6..c0ddc6fd76 100644 --- a/tests/resources/spec-openapi3.json +++ b/tests/resources/spec-openapi3.json @@ -652,6 +652,15 @@ "items": { "type": "object" } + }, + "labels": { + "type": "array", + "nullable": true, + "description": "Optional labels, including null values.", + "items": { + "type": "string", + "nullable": true + } } }, "required": [ From 462184ab460e89d348ea78f98071f0715aad8592 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 11 Sep 2026 15:29:46 +0530 Subject: [PATCH 3/7] test(python): observe validation at the HTTP boundary --- tests/e2e/languages/python/tests.py | 56 ++++++++++++++--------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index 7f3bd5f2da..7f9a019b78 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -14,12 +14,7 @@ import json import os.path - - -class NoRequestClient(Client): - def call(self, *args, **kwargs): - raise AssertionError('Invalid input reached the request boundary') - +import requests_mock client = Client() foo = Foo(client) @@ -75,33 +70,34 @@ def call(self, *args, **kwargs): # String-list validation follows the declared item type across query and body # parameters, before reaching the request boundary. -invalid_general = General(NoRequestClient()) -invalid_foo = Foo(NoRequestClient()) -for values in ['query', {'method': 'limit'}, [1], [True], [None], [['nested']], ['valid', {'method': 'limit'}]]: - calls = [ - (invalid_general.list_rows, {'queries': values}, 'queries'), - (invalid_foo.get, {'x': 'string', 'y': 123, 'z': values}, 'z'), - (invalid_foo.post, {'x': 'string', 'y': 123, 'z': values}, 'z'), - ] - if values != [None]: - calls.append((invalid_general.create_documents, {'documents': [{'$id': 'one'}], 'labels': values}, 'labels')) - for method, arguments, name in calls: +with requests_mock.Mocker() as http: + for values in ['query', {'method': 'limit'}, [1], [True], [None], [['nested']], ['valid', {'method': 'limit'}]]: + calls = [ + (general.list_rows, {'queries': values}, 'queries'), + (foo.get, {'x': 'string', 'y': 123, 'z': values}, 'z'), + (foo.post, {'x': 'string', 'y': 123, 'z': values}, 'z'), + ] + if values != [None]: + calls.append((general.create_documents, {'documents': [{'$id': 'one'}], 'labels': values}, 'labels')) + for method, arguments, name in calls: + try: + method(**arguments) + raise AssertionError('Invalid string list was accepted') + except AppwriteException as error: + if error.type != 'sdk_input_validation' or error.code != 0 or error.response is not None: + raise + if name not in error.message: + raise AssertionError('Validation error did not identify the parameter') + + for arguments, name in [({'x': None, 'y': 123, 'z': [1]}, 'x'), ({'x': 'string', 'y': 123, 'z': None}, 'z')]: try: - method(**arguments) - raise AssertionError('Invalid string list was accepted') + foo.get(**arguments) + raise AssertionError('Missing required parameter was accepted') except AppwriteException as error: - if error.type != 'sdk_input_validation' or error.code != 0 or error.response is not None: + if error.message != f'Missing required parameter: "{name}"': raise - if name not in error.message: - raise AssertionError('Validation error did not identify the parameter') - -for arguments, name in [({'x': None, 'y': 123, 'z': [1]}, 'x'), ({'x': 'string', 'y': 123, 'z': None}, 'z')]: - try: - invalid_foo.get(**arguments) - raise AssertionError('Missing required parameter was accepted') - except AppwriteException as error: - if error.message != f'Missing required parameter: "{name}"': - raise + if http.called: + raise AssertionError('Invalid input submitted an HTTP request') print('String list validation:passed') # String contents are unchanged; this validates types, not JSON syntax or enum From 69ef520b9f8e7ba6ccf38279a34f451841b5409a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 11 Sep 2026 17:17:06 +0530 Subject: [PATCH 4/7] test(python): check string-list behavior through printed responses Print the SDK validation messages and the mock's responses so ARRAY_PARAMETER_RESPONSES compares real output, replacing the requests_mock matrix and its passed markers. --- tests/e2e/Base.php | 12 ++- tests/e2e/languages/python/tests.py | 113 +++++++--------------------- 2 files changed, 36 insertions(+), 89 deletions(-) diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index c469794154..f5e5f6e762 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -45,11 +45,15 @@ abstract class Base extends TestCase 'GET:/v1/mock/tests/general/redirect/done:passed', ]; + // The mock never emits the first two messages: a request that reaches it + // cannot pass the rejection checks. protected const ARRAY_PARAMETER_RESPONSES = [ - 'String list validation:passed', - 'Query parameter serialization:passed', - 'Nested query validation:400', - 'POST:/v1/mock/tests/general/documents:passed', + 'Invalid parameter: "queries" must be a list of strings.', + 'Invalid parameter: "z" must be a list of strings.', + '["not JSON","first"]', + '{"result":"POST:/v1/mock/tests/general/documents:passed","documents":[{"$id":"first"}],"labels":["ready",null]}', + 'Invalid `queries` param: Value must a valid array no longer than 100 items and Value must be a valid string and at least 1 chars and no longer than 4096 chars', + '{"result":"POST:/v1/mock/tests/general/documents:passed","documents":[{"$id":"first","values":["0","1.5","true","false"]},{"$id":"second","nested":[{"name":"Zoë","values":[["1","2"]]}]}],"labels":null}', ]; protected const PATH_PARAM_RESPONSES = [ diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index 7f9a019b78..a427cc977d 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -14,7 +14,6 @@ import json import os.path -import requests_mock client = Client() foo = Foo(client) @@ -68,98 +67,42 @@ response = general.redirect() print(response['result']) -# String-list validation follows the declared item type across query and body -# parameters, before reaching the request boundary. -with requests_mock.Mocker() as http: - for values in ['query', {'method': 'limit'}, [1], [True], [None], [['nested']], ['valid', {'method': 'limit'}]]: - calls = [ - (general.list_rows, {'queries': values}, 'queries'), - (foo.get, {'x': 'string', 'y': 123, 'z': values}, 'z'), - (foo.post, {'x': 'string', 'y': 123, 'z': values}, 'z'), - ] - if values != [None]: - calls.append((general.create_documents, {'documents': [{'$id': 'one'}], 'labels': values}, 'labels')) - for method, arguments, name in calls: - try: - method(**arguments) - raise AssertionError('Invalid string list was accepted') - except AppwriteException as error: - if error.type != 'sdk_input_validation' or error.code != 0 or error.response is not None: - raise - if name not in error.message: - raise AssertionError('Validation error did not identify the parameter') - - for arguments, name in [({'x': None, 'y': 123, 'z': [1]}, 'x'), ({'x': 'string', 'y': 123, 'z': None}, 'z')]: - try: - foo.get(**arguments) - raise AssertionError('Missing required parameter was accepted') - except AppwriteException as error: - if error.message != f'Missing required parameter: "{name}"': - raise - if http.called: - raise AssertionError('Invalid input submitted an HTTP request') -print('String list validation:passed') - -# String contents are unchanged; this validates types, not JSON syntax or enum -# membership. Optional lists and normalized Enum values remain supported. -queries = [Query.equal('name', 'Zoë'), Query.limit(1)] -for values, expected in [ - (None, []), - ([], []), - (queries, queries), - (['not JSON', MockType.FIRST], ['not JSON', 'first']), -]: - if json.loads(general.list_rows(values).result) != expected: - raise AssertionError('Query parameter values changed during serialization') - -# The generic request serializer also handles scalar array values independently -# of generated service parameter validation. -response = client.call('get', '/mock/tests/general/list-rows', params={'queries': [0, 1.5, True, False]}) -if json.loads(response['result']) != ['0', '1.5', 'true', 'false']: - raise AssertionError('Scalar array values changed during serialization') -print('Query parameter serialization:passed') - -# Raw requests still reach API validation for invalid nested queries. -for values in [ - [{'method': 'limit', 'values': [1]}], - [['nested']], - [Query.limit(1), {'method': 'limit', 'values': [1]}], -]: - try: - client.call('get', '/mock/tests/general/list-rows', params={'queries': values}) - raise AssertionError('Nested query was accepted') - except AppwriteException as error: - if error.code != 400 or 'queries' not in error.message: - raise -print('Nested query validation:400') - -# Generated object-array parameters preserve their contents, and nullable -# string-list items follow the schema independently of outer optionality. -documents = [ - {'$id': 'first', 'values': [0, 1.5, True, False]}, - {'$id': 'second', 'nested': [{'name': 'Zoë', 'values': [[1, 2]]}]}, -] -for labels, expected in [(None, None), ([], []), (['ready', None], ['ready', None]), ([MockType.FIRST], ['first'])]: - response = general.create_documents(documents, labels=labels) - if response.to_dict()['documents'] != documents or response.to_dict()['labels'] != expected: - raise AssertionError('Object arrays or nullable string-list items changed') - -# Multipart serialization preserves the nested structure at the API boundary. +# String-list items are checked against the schema before any request. +try: + general.list_rows([{'method': 'limit', 'values': [1]}]) + raise AssertionError('Invalid string list was accepted') +except AppwriteException as error: + print(error.message) + +try: + foo.post('string', 123, [1]) + raise AssertionError('Invalid string list was accepted') +except AppwriteException as error: + print(error.message) + +print(general.list_rows(['not JSON', MockType.FIRST]).result) +print(json.dumps(general.create_documents([{'$id': 'first'}], labels=['ready', None]).to_dict())) + +# Nested lists serialize by index, so raw calls reach API validation. +try: + client.call('get', '/mock/tests/general/list-rows', params={'queries': [{'method': 'limit', 'values': [1]}]}) + raise AssertionError('Nested query was accepted') +except AppwriteException as error: + print(error.message) + response = client.call( 'post', '/mock/tests/general/documents', {'content-type': 'multipart/form-data', 'X-Appwrite-Project': 'console'}, { - 'documents': documents, + 'documents': [ + {'$id': 'first', 'values': [0, 1.5, True, False]}, + {'$id': 'second', 'nested': [{'name': 'Zoë', 'values': [[1, 2]]}]}, + ], 'file': InputFile.from_bytes(b'fixture', 'fixture.txt', 'text/plain'), }, ) -if response['documents'] != [ - {'$id': 'first', 'values': ['0', '1.5', 'true', 'false']}, - {'$id': 'second', 'nested': [{'name': 'Zoë', 'values': [['1', '2']]}]}, -]: - raise AssertionError('Multipart document values or nesting changed') -print(response['result']) +print(json.dumps(response)) for id, plain in [('', '0'), ('0', '')]: try: From 58f422e293b3a626eabe10a91819283ec32a4f25 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 11 Sep 2026 17:25:35 +0530 Subject: [PATCH 5/7] test(python): print the full string-list validation exception Include type, code and response alongside the message so the e2e output covers the exception fields callers branch on. --- tests/e2e/Base.php | 6 +++--- tests/e2e/languages/python/tests.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index f5e5f6e762..98b0af09dc 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -45,11 +45,11 @@ abstract class Base extends TestCase 'GET:/v1/mock/tests/general/redirect/done:passed', ]; - // The mock never emits the first two messages: a request that reaches it + // The mock never emits the first two exceptions: a request that reaches it // cannot pass the rejection checks. protected const ARRAY_PARAMETER_RESPONSES = [ - 'Invalid parameter: "queries" must be a list of strings.', - 'Invalid parameter: "z" must be a list of strings.', + '{"message":"Invalid parameter: \"queries\" must be a list of strings.","type":"sdk_input_validation","code":0,"response":null}', + '{"message":"Invalid parameter: \"z\" must be a list of strings.","type":"sdk_input_validation","code":0,"response":null}', '["not JSON","first"]', '{"result":"POST:/v1/mock/tests/general/documents:passed","documents":[{"$id":"first"}],"labels":["ready",null]}', 'Invalid `queries` param: Value must a valid array no longer than 100 items and Value must be a valid string and at least 1 chars and no longer than 4096 chars', diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index a427cc977d..d089714be6 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -72,13 +72,13 @@ general.list_rows([{'method': 'limit', 'values': [1]}]) raise AssertionError('Invalid string list was accepted') except AppwriteException as error: - print(error.message) + print(json.dumps({'message': error.message, 'type': error.type, 'code': error.code, 'response': error.response})) try: foo.post('string', 123, [1]) raise AssertionError('Invalid string list was accepted') except AppwriteException as error: - print(error.message) + print(json.dumps({'message': error.message, 'type': error.type, 'code': error.code, 'response': error.response})) print(general.list_rows(['not JSON', MockType.FIRST]).result) print(json.dumps(general.create_documents([{'$id': 'first'}], labels=['ready', None]).to_dict())) From f865ca124d762c297f655a38dab9edc0346ca3fd Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 11 Sep 2026 18:42:10 +0530 Subject: [PATCH 6/7] fix: validate string-list parameters across SDKs Generate the schema-driven string-list check for Node, Web, Deno, React Native, PHP, Ruby, Kotlin and Android, matching Python, and send PHP enum objects in GET query lists as their values. Split the e2e contract into ARRAY_PARAMETER_RESPONSES for every SDK, STRING_LIST_VALIDATION_RESPONSES for SDKs with the check, and Python-only NESTED_LIST_RESPONSES. The mock echoes createDocuments input inside result so typed models can print it. --- mock-server/app/http.php | 4 +-- .../src/main/java/io/package/Service.kt.twig | 11 ++++++- .../java/io/package/services/Service.kt.twig | 5 ++++ templates/deno/src/client.ts.twig | 11 +++++++ templates/deno/src/services/service.ts.twig | 6 ++++ .../io/appwrite/services/Service.kt.twig | 11 ++++++- .../appwrite/services/ServiceTemplate.kt.twig | 5 ++++ templates/node/src/client.ts.twig | 26 +++++++++++++++++ templates/node/src/services/template.ts.twig | 9 ++++++ templates/php/base/params.twig | 6 ++++ templates/php/src/Client.php.twig | 9 ++++-- templates/php/src/Service.php.twig | 18 ++++++++++++ templates/react-native/src/client.ts.twig | 26 +++++++++++++++++ .../src/services/template.ts.twig | 20 +++++++++++++ templates/ruby/base/params.twig | 6 ++++ templates/ruby/lib/container/service.rb.twig | 9 ++++++ templates/rust/tests/tests.rs | 14 +++++++++ templates/web/src/client.ts.twig | 26 +++++++++++++++++ templates/web/src/services/template.ts.twig | 9 ++++++ tests/e2e/Android16Java17Test.php | 2 ++ tests/e2e/Android5Java17Test.php | 2 ++ tests/e2e/AppleSwift61Test.php | 2 +- tests/e2e/Base.php | 22 ++++++++------ tests/e2e/DartBetaTest.php | 1 + tests/e2e/DartStableTest.php | 1 + tests/e2e/Deno1193Test.php | 2 ++ tests/e2e/Deno1303Test.php | 2 ++ tests/e2e/DotNet60Test.php | 1 + tests/e2e/DotNet80Test.php | 1 + tests/e2e/DotNet90Test.php | 1 + tests/e2e/FlutterBetaTest.php | 1 + tests/e2e/FlutterStableTest.php | 1 + tests/e2e/Go113Test.php | 1 + tests/e2e/Go118Test.php | 1 + tests/e2e/KotlinJava17Test.php | 2 ++ tests/e2e/Node18Test.php | 2 ++ tests/e2e/Node20Test.php | 2 ++ tests/e2e/Node26Test.php | 2 ++ tests/e2e/PHP85Test.php | 2 ++ tests/e2e/Python310Test.php | 2 ++ tests/e2e/Python311Test.php | 2 ++ tests/e2e/Python312Test.php | 2 ++ tests/e2e/Python313Test.php | 2 ++ tests/e2e/Python39Test.php | 2 ++ tests/e2e/ReactNativeTest.php | 2 ++ tests/e2e/Ruby27Test.php | 2 ++ tests/e2e/Ruby30Test.php | 2 ++ tests/e2e/Ruby31Test.php | 2 ++ tests/e2e/Rust183Test.php | 1 + tests/e2e/Swift61Test.php | 2 +- tests/e2e/Unity2021Test.php | 1 + tests/e2e/WebChromiumTest.php | 2 ++ tests/e2e/WebNodeTest.php | 2 ++ tests/e2e/languages/android/Tests.kt | 19 ++++++++++++ tests/e2e/languages/apple/Tests.swift | 12 ++++---- tests/e2e/languages/dart/tests.dart | 6 ++++ tests/e2e/languages/deno/tests.ts | 29 +++++++++++++++++++ tests/e2e/languages/dotnet/Tests.cs | 10 +++++++ tests/e2e/languages/flutter/tests.dart | 6 ++++ tests/e2e/languages/go-v2/tests.go | 15 ++++++++++ tests/e2e/languages/go/tests.go | 15 ++++++++++ tests/e2e/languages/kotlin/Tests.kt | 19 ++++++++++++ tests/e2e/languages/node/test.js | 26 +++++++++++++++++ tests/e2e/languages/php/test.php | 20 +++++++++++++ tests/e2e/languages/python/tests.py | 8 +++-- tests/e2e/languages/react-native/browser.js | 26 +++++++++++++++++ tests/e2e/languages/ruby/tests.rb | 20 +++++++++++++ tests/e2e/languages/swift/Tests.swift | 13 +++++---- tests/e2e/languages/unity/Tests.cs | 10 +++++++ tests/e2e/languages/web/index.html | 26 +++++++++++++++++ tests/e2e/languages/web/node.js | 26 +++++++++++++++++ 71 files changed, 581 insertions(+), 33 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 3f7300688b..e258b6e4a3 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -669,9 +669,7 @@ } $response->json([ - 'result' => 'POST:/v1/mock/tests/general/documents:passed', - 'documents' => $documents, - 'labels' => $labels, + 'result' => \json_encode(['documents' => $documents, 'labels' => $labels]), ]); }); diff --git a/templates/android/library/src/main/java/io/package/Service.kt.twig b/templates/android/library/src/main/java/io/package/Service.kt.twig index 332fc958ab..8b8e1b8302 100644 --- a/templates/android/library/src/main/java/io/package/Service.kt.twig +++ b/templates/android/library/src/main/java/io/package/Service.kt.twig @@ -1,10 +1,19 @@ package {{ sdk.namespace | caseDot }} import {{ sdk.namespace | caseDot }}.Client +import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.info.title | caseUcfirst }}Exception /** * Abstract class for services. * * @param client The Appwrite client. */ -abstract class Service(val client: Client) +abstract class Service(val client: Client) { + // Erased generics let other item types through casts and Java callers. + // Enum items are allowed because they serialize to their string value. + protected fun validateStringList(name: String, value: List<*>?, nullableItems: Boolean = false) { + if (value?.any { it !is String && it !is Enum<*> && !(nullableItems && it == null) } == true) { + throw {{ spec.info.title | caseUcfirst }}Exception("Invalid parameter: \"$name\" must be a list of strings.", 0, "sdk_input_validation") + } + } +} diff --git a/templates/android/library/src/main/java/io/package/services/Service.kt.twig b/templates/android/library/src/main/java/io/package/services/Service.kt.twig index c9598b2f21..ce5153442b 100644 --- a/templates/android/library/src/main/java/io/package/services/Service.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Service.kt.twig @@ -79,6 +79,11 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { throw {{ spec.info.title | caseUcfirst }}Exception("Missing required parameter: \"{{ parameter.name | caseCamel }}\"") } {%~ endif %} + {%~ endfor %} + {%~ for parameter in allParameters %} + {%~ if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} + validateStringList("{{ parameter.name | caseCamel }}", {{ parameter.name | caseCamel }}{% if (parameter | arraySchema) | schemaNullable %}, nullableItems = true{% endif %}) + {%~ endif %} {%~ endfor %} val apiPath = {% if pathParameters is empty and securityQueries is empty %}"{{ method.path }}"{% else %}("{{ method.path }}" {%~ for parameter in pathParameters %} diff --git a/templates/deno/src/client.ts.twig b/templates/deno/src/client.ts.twig index 7f670efafb..cd7ce24b7d 100644 --- a/templates/deno/src/client.ts.twig +++ b/templates/deno/src/client.ts.twig @@ -156,6 +156,17 @@ export class Client { return json; } + static validateStringList(name: string, value: unknown, nullableItems: boolean = false): void { + // Missing values are left to the generated required-parameter checks. + if (value === null || typeof value === 'undefined') { + return; + } + + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' && !(nullableItems && item === null))) { + throw new {{spec.info.title | caseUcfirst}}Exception(`Invalid parameter: "${name}" must be a list of strings.`, 0, 'sdk_input_validation'); + } + } + static flatten(data: Payload, prefix = ''): Payload { let output: Payload = {}; diff --git a/templates/deno/src/services/service.ts.twig b/templates/deno/src/services/service.ts.twig index e0f049e0d9..b385fe6b08 100644 --- a/templates/deno/src/services/service.ts.twig +++ b/templates/deno/src/services/service.ts.twig @@ -87,6 +87,12 @@ export class {{ service.name | caseUcfirst }} extends Service { throw new {{spec.info.title | caseUcfirst}}Exception('Missing required parameter: "{{ parameter.name | caseCamel | escapeKeyword }}"'); } +{% endif %} +{% endfor %} +{% for parameter in (method | parameters('all')) %} +{% if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} + Client.validateStringList('{{ parameter.name | caseCamel | escapeKeyword }}', {{ parameter.name | caseCamel | escapeKeyword }}{% if (parameter | arraySchema) | schemaNullable %}, true{% endif %}); + {% endif %} {% endfor %} let apiPath = '{{ method.path }}'{% for parameter in (method | parameters('path')) %}.replace('{{ '{' }}{{ parameter.name }}{{ '}' }}', {% if (parameter.extensions['x-sdk-source']|default('')) == 'security' %}this.client.config['{{ parameter.extensions['x-sdk-config'] | caseLower }}']{% else %}{{ parameter.name | caseCamel | escapeKeyword }}{% endif %}){% endfor %}; diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig index a6b5623fc9..27c64f108d 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig @@ -1,5 +1,14 @@ package {{ sdk.namespace | caseDot }}.services import {{ sdk.namespace | caseDot }}.Client +import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.info.title | caseUcfirst }}Exception -abstract class Service(val client: Client) +abstract class Service(val client: Client) { + // Erased generics let other item types through casts and Java callers. + // Enum items are allowed because they serialize to their string value. + protected fun validateStringList(name: String, value: List<*>?, nullableItems: Boolean = false) { + if (value?.any { it !is String && it !is Enum<*> && !(nullableItems && it == null) } == true) { + throw {{ spec.info.title | caseUcfirst }}Exception("Invalid parameter: \"$name\" must be a list of strings.", 0, "sdk_input_validation") + } + } +} diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig index 2385578d35..29b63a4e27 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig @@ -70,6 +70,11 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { throw {{ spec.info.title | caseUcfirst }}Exception("Missing required parameter: \"{{ parameter.name | caseCamel }}\"") } {%~ endif %} + {%~ endfor %} + {%~ for parameter in allParameters %} + {%~ if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} + validateStringList("{{ parameter.name | caseCamel }}", {{ parameter.name | caseCamel }}{% if (parameter | arraySchema) | schemaNullable %}, nullableItems = true{% endif %}) + {%~ endif %} {%~ endfor %} val apiPath = {% if pathParameters is empty and securityQueries is empty %}"{{ method.path }}"{% else %}("{{ method.path }}" {%~ for parameter in pathParameters %} diff --git a/templates/node/src/client.ts.twig b/templates/node/src/client.ts.twig index 7d31757a64..f4629be273 100644 --- a/templates/node/src/client.ts.twig +++ b/templates/node/src/client.ts.twig @@ -696,6 +696,32 @@ class Client { return data; } + static validateStringList( + name: string, + value: unknown, + nullableItems = false, + ): void { + // Missing values are left to the generated required-parameter checks. + if (value === null || typeof value === 'undefined') { + return; + } + + if ( + !Array.isArray(value) || + value.some( + (item) => + typeof item !== 'string' && + !(nullableItems && item === null), + ) + ) { + throw new {{spec.info.title | caseUcfirst}}Exception( + `Invalid parameter: "${name}" must be a list of strings.`, + 0, + 'sdk_input_validation', + ); + } + } + static flatten(data: Payload, prefix = ''): Payload { let output: Payload = {}; diff --git a/templates/node/src/services/template.ts.twig b/templates/node/src/services/template.ts.twig index c7aba2277c..60e94e9324 100644 --- a/templates/node/src/services/template.ts.twig +++ b/templates/node/src/services/template.ts.twig @@ -138,6 +138,15 @@ export class {{ service.name | caseUcfirst }} { } {%~ endif %} {%~ endfor %} + {%~ for parameter in (method | parameters('all')) %} + {%~ if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} + {%~ set validationArgs = ["'" ~ (parameter.name | caseCamel | escapeKeyword) ~ "'", (parameter.name | caseCamel | escapeKeyword)] %} + {%~ if (parameter | arraySchema) | schemaNullable %} + {%~ set validationArgs = validationArgs | merge(['true']) %} + {%~ endif %} + {{ 'Client.validateStringList' | jsArgs(validationArgs, 8) | raw }} + {%~ endif %} + {%~ endfor %} {% set pathReplacements = [] %}{% for parameter in (method | parameters('path')) %}{% set pathReplacements = pathReplacements|merge([['{' ~ parameter.name ~ '}', 'encodeURIComponent(String(' ~ (((parameter.extensions['x-sdk-source']|default('')) == 'security') ? ('this.client.config.' ~ (parameter.extensions['x-sdk-config'] | caseLower)) : (parameter.name | caseCamel | escapeKeyword)) ~ '))']]) %}{% endfor %} {{ method.path | tsApiPath(pathReplacements, 8) | raw }} const apiPayload: Payload = {}; diff --git a/templates/php/base/params.twig b/templates/php/base/params.twig index ac781a37c6..da1f0c0b18 100644 --- a/templates/php/base/params.twig +++ b/templates/php/base/params.twig @@ -1,3 +1,9 @@ +{% for parameter in (method | parameters('all')) %} +{% if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} + $this->validateStringList('{{ parameter.name | caseCamel }}', ${{ parameter.name | caseCamel | escapeKeyword }}{% if (parameter | arraySchema) | schemaNullable %}, nullableItems: true{% endif %}); + +{% endif %} +{% endfor %} $apiParams = []; {% if (method | parameters('all')) | length %} {% for parameter in (method | parameters('all')) %} diff --git a/templates/php/src/Client.php.twig b/templates/php/src/Client.php.twig index 87b7e5e2e6..3591871f60 100644 --- a/templates/php/src/Client.php.twig +++ b/templates/php/src/Client.php.twig @@ -295,7 +295,7 @@ class Client $method === self::METHOD_GET => $this->requestFactory->query( $method, $uri, - $params, + $this->prepareParams($params), $headers ), $contentType === 'application/json' => $this->requestFactory->json( @@ -374,7 +374,8 @@ class Client } /** - * Prepare params for JSON encoding by converting model objects to arrays + * Prepare params for encoding by converting model objects to arrays and + * enum objects to their values * */ protected function prepareParams($data) @@ -387,6 +388,10 @@ class Client return $data->toArray(); } + if ($data instanceof \JsonSerializable) { + return $data->jsonSerialize(); + } + return $data; } diff --git a/templates/php/src/Service.php.twig b/templates/php/src/Service.php.twig index 394390e1d1..b121e2b760 100644 --- a/templates/php/src/Service.php.twig +++ b/templates/php/src/Service.php.twig @@ -9,4 +9,22 @@ abstract class Service public function __construct(protected Client $client) { } + + protected function validateStringList(string $name, ?array $value, bool $nullableItems = false): void + { + if ($value === null) { + return; + } + + // Enum objects serialize to their string value. + $valid = array_is_list($value) && array_all( + $value, + fn (mixed $item): bool => ($nullableItems && $item === null) + || is_string($item instanceof \JsonSerializable ? $item->jsonSerialize() : $item) + ); + + if (!$valid) { + throw new {{ namespace | split('\\') | last | caseUcfirst }}Exception('Invalid parameter: "' . $name . '" must be a list of strings.', 0, 'sdk_input_validation'); + } + } } diff --git a/templates/react-native/src/client.ts.twig b/templates/react-native/src/client.ts.twig index fc5307ed63..fef258995f 100644 --- a/templates/react-native/src/client.ts.twig +++ b/templates/react-native/src/client.ts.twig @@ -785,6 +785,32 @@ class Client { throw new {{spec.info.title | caseUcfirst}}Exception((e).message); } } + + static validateStringList( + name: string, + value: unknown, + nullableItems = false, + ): void { + // Missing values are left to the generated required-parameter checks. + if (value === null || typeof value === 'undefined') { + return; + } + + if ( + !Array.isArray(value) || + value.some( + (item) => + typeof item !== 'string' && + !(nullableItems && item === null), + ) + ) { + throw new {{spec.info.title | caseUcfirst}}Exception( + `Invalid parameter: "${name}" must be a list of strings.`, + 0, + 'sdk_input_validation', + ); + } + } } export { Client, {{spec.info.title | caseUcfirst}}Exception }; diff --git a/templates/react-native/src/services/template.ts.twig b/templates/react-native/src/services/template.ts.twig index 8da92025be..ca9b292f21 100644 --- a/templates/react-native/src/services/template.ts.twig +++ b/templates/react-native/src/services/template.ts.twig @@ -142,6 +142,16 @@ export class {{ service.name | caseUcfirst }} extends Service { {{ ('throw new ' ~ (spec.info.title | caseUcfirst) ~ 'Exception') | tsCall('\'Missing required parameter: "' ~ (parameter.name | caseCamel | escapeKeyword) ~ '"\'', ';', 12) | raw }} } +{% endif %} +{% endfor %} +{% for parameter in (method | parameters('all')) %} +{% if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} +{% set validationArgs = ["'" ~ (parameter.name | caseCamel | escapeKeyword) ~ "'", (parameter.name | caseCamel | escapeKeyword)] %} +{% if (parameter | arraySchema) | schemaNullable %} +{% set validationArgs = validationArgs | merge(['true']) %} +{% endif %} + {{ 'Client.validateStringList' | jsArgs(validationArgs, 8) | raw }} + {% endif %} {% endfor %} {% set pathReplacements = [] %}{% for parameter in (method | parameters('path')) %}{% set pathReplacements = pathReplacements|merge([['{' ~ parameter.name ~ '}', 'encodeURIComponent(String(' ~ (((parameter.extensions['x-sdk-source']|default('')) == 'security') ? ('this.client.config.' ~ (parameter.extensions['x-sdk-config'] | caseLower)) : (parameter.name | caseCamel | escapeKeyword)) ~ '))']]) %}{% endfor %} @@ -462,6 +472,16 @@ export class {{ service.name | caseUcfirst }} extends Service { {{ ('throw new ' ~ (spec.info.title | caseUcfirst) ~ 'Exception') | tsCall('\'Missing required parameter: "' ~ (parameter.name | caseCamel | escapeKeyword) ~ '"\'', ';', 12) | raw }} } +{% endif %} +{% endfor %} +{% for parameter in (method | parameters('all')) %} +{% if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} +{% set validationArgs = ["'" ~ (parameter.name | caseCamel | escapeKeyword) ~ "'", (parameter.name | caseCamel | escapeKeyword)] %} +{% if (parameter | arraySchema) | schemaNullable %} +{% set validationArgs = validationArgs | merge(['true']) %} +{% endif %} + {{ 'Client.validateStringList' | jsArgs(validationArgs, 8) | raw }} + {% endif %} {% endfor %} {% set pathReplacements = [] %}{% for parameter in (method | parameters('path')) %}{% set pathReplacements = pathReplacements|merge([['{' ~ parameter.name ~ '}', 'encodeURIComponent(String(' ~ (((parameter.extensions['x-sdk-source']|default('')) == 'security') ? ('this.client.config.' ~ (parameter.extensions['x-sdk-config'] | caseLower)) : (parameter.name | caseCamel | escapeKeyword)) ~ '))']]) %}{% endfor %} diff --git a/templates/ruby/base/params.twig b/templates/ruby/base/params.twig index 83a03527bb..a01f0c4164 100644 --- a/templates/ruby/base/params.twig +++ b/templates/ruby/base/params.twig @@ -7,6 +7,12 @@ raise {{spec.info.title | caseUcfirst}}::Exception.new('Missing required parameter: "{{ parameter.name | caseCamel | escapeKeyword }}"') end +{% endif %} +{% endfor %} +{% for parameter in (method | parameters('all')) %} +{% if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} + validate_string_list('{{ parameter.name | caseCamel | escapeKeyword }}', {{ parameter.name | caseSnake | escapeKeyword }}{% if (parameter | arraySchema) | schemaNullable %}, nullable_items: true{% endif %}) + {% endif %} {% endfor %} api_path = '{{ method.path }}' diff --git a/templates/ruby/lib/container/service.rb.twig b/templates/ruby/lib/container/service.rb.twig index 7468cfd86d..0fd4047559 100644 --- a/templates/ruby/lib/container/service.rb.twig +++ b/templates/ruby/lib/container/service.rb.twig @@ -3,5 +3,14 @@ module {{spec.info.title | caseUcfirst }} def initialize(client) @client = client end + + private + + def validate_string_list(name, value, nullable_items: false) + return if value.nil? + return if value.is_a?(Array) && value.all? { |item| item.is_a?(String) || (nullable_items && item.nil?) } + + raise {{spec.info.title | caseUcfirst}}::Exception.new("Invalid parameter: \"#{name}\" must be a list of strings.", 0, 'sdk_input_validation') + end end end diff --git a/templates/rust/tests/tests.rs b/templates/rust/tests/tests.rs index 227457870c..06f8132c5a 100644 --- a/templates/rust/tests/tests.rs +++ b/templates/rust/tests/tests.rs @@ -1,5 +1,6 @@ use appwrite::{ Client, + enums::MockType, id::ID, input_file::InputFile, operator::{self, Condition}, @@ -112,6 +113,19 @@ async fn test_general_service(client: &Client, string_in_array: &[String]) -> Re Err(e) => eprintln!("general.redirected => error {}", e), } + let queries = vec!["not JSON".to_string(), MockType::First.to_string()]; + match general.list_rows(Some(queries)).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("general.list_rows => error {}", e), + } + + let documents = vec![json!({"$id": "first"})]; + let labels = vec!["ready".to_string()]; + match general.create_documents(documents, Some(labels)).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("general.create_documents => error {}", e), + } + for (id, plain) in [("", "0"), ("0", "")] { let error = general.validate_path(plain, Some(id)).await.unwrap_err(); assert_eq!(error.code, 0); diff --git a/templates/web/src/client.ts.twig b/templates/web/src/client.ts.twig index 4d68244ca3..1ded99a1d4 100644 --- a/templates/web/src/client.ts.twig +++ b/templates/web/src/client.ts.twig @@ -1265,6 +1265,32 @@ class Client { return data; } + static validateStringList( + name: string, + value: unknown, + nullableItems = false, + ): void { + // Missing values are left to the generated required-parameter checks. + if (value === null || typeof value === 'undefined') { + return; + } + + if ( + !Array.isArray(value) || + value.some( + (item) => + typeof item !== 'string' && + !(nullableItems && item === null), + ) + ) { + throw new {{spec.info.title | caseUcfirst}}Exception( + `Invalid parameter: "${name}" must be a list of strings.`, + 0, + 'sdk_input_validation', + ); + } + } + static flatten(data: Payload, prefix = ''): Payload { let output: Payload = {}; diff --git a/templates/web/src/services/template.ts.twig b/templates/web/src/services/template.ts.twig index faa3231a0c..7785ea9711 100644 --- a/templates/web/src/services/template.ts.twig +++ b/templates/web/src/services/template.ts.twig @@ -141,6 +141,15 @@ export class {{ service.name | caseUcfirst }} { } {%~ endif %} {%~ endfor %} + {%~ for parameter in (method | parameters('all')) %} + {%~ if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} + {%~ set validationArgs = ["'" ~ (parameter.name | caseCamel | escapeKeyword) ~ "'", (parameter.name | caseCamel | escapeKeyword)] %} + {%~ if (parameter | arraySchema) | schemaNullable %} + {%~ set validationArgs = validationArgs | merge(['true']) %} + {%~ endif %} + {{ 'Client.validateStringList' | jsArgs(validationArgs, 8) | raw }} + {%~ endif %} + {%~ endfor %} {% set pathReplacements = [] %}{% for parameter in (method | parameters('path')) %}{% set pathReplacements = pathReplacements|merge([['{' ~ parameter.name ~ '}', 'encodeURIComponent(String(' ~ (((parameter.extensions['x-sdk-source']|default('')) == 'security') ? ('this.client.config.' ~ (parameter.extensions['x-sdk-config'] | caseLower)) : (parameter.name | caseCamel | escapeKeyword)) ~ '))']]) %}{% endfor %} {{ method.path | tsApiPath(pathReplacements, 8) | raw }} const payload: Payload = {}; diff --git a/tests/e2e/Android16Java17Test.php b/tests/e2e/Android16Java17Test.php index 89b36066ae..329fe21d06 100644 --- a/tests/e2e/Android16Java17Test.php +++ b/tests/e2e/Android16Java17Test.php @@ -38,6 +38,8 @@ final class Android16Java17Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/Android5Java17Test.php b/tests/e2e/Android5Java17Test.php index 919f3e411f..2cd22e5d14 100644 --- a/tests/e2e/Android5Java17Test.php +++ b/tests/e2e/Android5Java17Test.php @@ -38,6 +38,8 @@ final class Android5Java17Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/AppleSwift61Test.php b/tests/e2e/AppleSwift61Test.php index 9824bf25e4..590664ff2a 100644 --- a/tests/e2e/AppleSwift61Test.php +++ b/tests/e2e/AppleSwift61Test.php @@ -37,13 +37,13 @@ final class AppleSwift61Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, ...Base::ENUM_RESPONSES, ...Base::MODEL_RESPONSES, - ...Base::OBJECT_ARRAY_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, ...Base::COOKIE_RESPONSES, diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index 98b0af09dc..177146b5bc 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -45,15 +45,23 @@ abstract class Base extends TestCase 'GET:/v1/mock/tests/general/redirect/done:passed', ]; - // The mock never emits the first two exceptions: a request that reaches it - // cannot pass the rejection checks. + // One contract for every SDK: string lists reach the API unchanged. protected const ARRAY_PARAMETER_RESPONSES = [ + '["not JSON","first"]', + '{"documents":[{"$id":"first"}],"labels":["ready"]}', + ]; + + // SDKs that can receive a non-string list item at runtime reject it before + // any request; the mock never emits these exceptions. + protected const STRING_LIST_VALIDATION_RESPONSES = [ '{"message":"Invalid parameter: \"queries\" must be a list of strings.","type":"sdk_input_validation","code":0,"response":null}', '{"message":"Invalid parameter: \"z\" must be a list of strings.","type":"sdk_input_validation","code":0,"response":null}', - '["not JSON","first"]', - '{"result":"POST:/v1/mock/tests/general/documents:passed","documents":[{"$id":"first"}],"labels":["ready",null]}', + '{"documents":[{"$id":"first"}],"labels":["ready",null]}', + ]; + + protected const NESTED_LIST_RESPONSES = [ 'Invalid `queries` param: Value must a valid array no longer than 100 items and Value must be a valid string and at least 1 chars and no longer than 4096 chars', - '{"result":"POST:/v1/mock/tests/general/documents:passed","documents":[{"$id":"first","values":["0","1.5","true","false"]},{"$id":"second","nested":[{"name":"Zoë","values":[["1","2"]]}]}],"labels":null}', + '{"documents":[{"$id":"first","values":["0","1.5","true","false"]},{"$id":"second","nested":[{"name":"Zoë","values":[["1","2"]]}]}],"labels":null}', ]; protected const PATH_PARAM_RESPONSES = [ @@ -96,10 +104,6 @@ abstract class Base extends TestCase 'POST:/v1/mock/tests/general/models/array:passed', ]; - protected const OBJECT_ARRAY_RESPONSES = [ - 'POST:/v1/mock/tests/general/documents:passed', - ]; - protected const OPTIONAL_PARAM_RESPONSES = [ 'width=-1,height=128,name=omitted', 'width=0,height=64,name=zero', diff --git a/tests/e2e/DartBetaTest.php b/tests/e2e/DartBetaTest.php index d7be20e5be..ded2aa5ceb 100644 --- a/tests/e2e/DartBetaTest.php +++ b/tests/e2e/DartBetaTest.php @@ -37,6 +37,7 @@ final class DartBetaTest extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/DartStableTest.php b/tests/e2e/DartStableTest.php index 8a1c8b4512..110a97fd00 100644 --- a/tests/e2e/DartStableTest.php +++ b/tests/e2e/DartStableTest.php @@ -37,6 +37,7 @@ final class DartStableTest extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/Deno1193Test.php b/tests/e2e/Deno1193Test.php index cfeb21a39c..909a4d15a2 100644 --- a/tests/e2e/Deno1193Test.php +++ b/tests/e2e/Deno1193Test.php @@ -33,6 +33,8 @@ final class Deno1193Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Deno1303Test.php b/tests/e2e/Deno1303Test.php index 3b07a6887f..d8460e3009 100644 --- a/tests/e2e/Deno1303Test.php +++ b/tests/e2e/Deno1303Test.php @@ -33,6 +33,8 @@ final class Deno1303Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/DotNet60Test.php b/tests/e2e/DotNet60Test.php index ad8cf173f2..74c744c6ba 100644 --- a/tests/e2e/DotNet60Test.php +++ b/tests/e2e/DotNet60Test.php @@ -37,6 +37,7 @@ final class DotNet60Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/DotNet80Test.php b/tests/e2e/DotNet80Test.php index 564c5b081f..7e93f68de3 100644 --- a/tests/e2e/DotNet80Test.php +++ b/tests/e2e/DotNet80Test.php @@ -37,6 +37,7 @@ final class DotNet80Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/DotNet90Test.php b/tests/e2e/DotNet90Test.php index fd039e4ec5..7630d80fe0 100644 --- a/tests/e2e/DotNet90Test.php +++ b/tests/e2e/DotNet90Test.php @@ -37,6 +37,7 @@ final class DotNet90Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/FlutterBetaTest.php b/tests/e2e/FlutterBetaTest.php index 21fa8c35fd..32385b81ee 100644 --- a/tests/e2e/FlutterBetaTest.php +++ b/tests/e2e/FlutterBetaTest.php @@ -37,6 +37,7 @@ final class FlutterBetaTest extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/FlutterStableTest.php b/tests/e2e/FlutterStableTest.php index 27548c7f94..e57f6f5a2b 100644 --- a/tests/e2e/FlutterStableTest.php +++ b/tests/e2e/FlutterStableTest.php @@ -37,6 +37,7 @@ final class FlutterStableTest extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/Go113Test.php b/tests/e2e/Go113Test.php index 9cef5ef9e5..68622108ac 100644 --- a/tests/e2e/Go113Test.php +++ b/tests/e2e/Go113Test.php @@ -35,6 +35,7 @@ final class Go113Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/Go118Test.php b/tests/e2e/Go118Test.php index 504aeefd3a..be2f37070e 100644 --- a/tests/e2e/Go118Test.php +++ b/tests/e2e/Go118Test.php @@ -35,6 +35,7 @@ final class Go118Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/KotlinJava17Test.php b/tests/e2e/KotlinJava17Test.php index 5b5e3fae51..f82519b7b7 100644 --- a/tests/e2e/KotlinJava17Test.php +++ b/tests/e2e/KotlinJava17Test.php @@ -38,6 +38,8 @@ final class KotlinJava17Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/Node18Test.php b/tests/e2e/Node18Test.php index dfb5bdb3c8..798ab84a25 100644 --- a/tests/e2e/Node18Test.php +++ b/tests/e2e/Node18Test.php @@ -40,6 +40,8 @@ final class Node18Test extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/Node20Test.php b/tests/e2e/Node20Test.php index d8c00978ec..499804f658 100644 --- a/tests/e2e/Node20Test.php +++ b/tests/e2e/Node20Test.php @@ -40,6 +40,8 @@ final class Node20Test extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/Node26Test.php b/tests/e2e/Node26Test.php index 60817df451..7ce07cd573 100644 --- a/tests/e2e/Node26Test.php +++ b/tests/e2e/Node26Test.php @@ -40,6 +40,8 @@ final class Node26Test extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/PHP85Test.php b/tests/e2e/PHP85Test.php index 54db9357c4..8dfffe2624 100644 --- a/tests/e2e/PHP85Test.php +++ b/tests/e2e/PHP85Test.php @@ -35,6 +35,8 @@ final class PHP85Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UNION_RESPONSES, 'compound specialization: passed', diff --git a/tests/e2e/Python310Test.php b/tests/e2e/Python310Test.php index 5bb39c66b6..1dba9552d8 100644 --- a/tests/e2e/Python310Test.php +++ b/tests/e2e/Python310Test.php @@ -38,6 +38,8 @@ final class Python310Test extends Base ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, + ...Base::NESTED_LIST_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Python311Test.php b/tests/e2e/Python311Test.php index 200d77fa87..56a15bd679 100644 --- a/tests/e2e/Python311Test.php +++ b/tests/e2e/Python311Test.php @@ -38,6 +38,8 @@ final class Python311Test extends Base ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, + ...Base::NESTED_LIST_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Python312Test.php b/tests/e2e/Python312Test.php index a58d4caef7..44bf5b4ddc 100644 --- a/tests/e2e/Python312Test.php +++ b/tests/e2e/Python312Test.php @@ -38,6 +38,8 @@ final class Python312Test extends Base ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, + ...Base::NESTED_LIST_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Python313Test.php b/tests/e2e/Python313Test.php index 13330b4c83..1521a5dbd3 100644 --- a/tests/e2e/Python313Test.php +++ b/tests/e2e/Python313Test.php @@ -38,6 +38,8 @@ final class Python313Test extends Base ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, + ...Base::NESTED_LIST_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Python39Test.php b/tests/e2e/Python39Test.php index 3833a082bf..ae67c7b730 100644 --- a/tests/e2e/Python39Test.php +++ b/tests/e2e/Python39Test.php @@ -38,6 +38,8 @@ final class Python39Test extends Base ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, + ...Base::NESTED_LIST_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/ReactNativeTest.php b/tests/e2e/ReactNativeTest.php index de55ec0e6b..26e2ad66d9 100644 --- a/tests/e2e/ReactNativeTest.php +++ b/tests/e2e/ReactNativeTest.php @@ -46,6 +46,8 @@ final class ReactNativeTest extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::DOWNLOAD_RESPONSES, ...Base::ENUM_RESPONSES, diff --git a/tests/e2e/Ruby27Test.php b/tests/e2e/Ruby27Test.php index cd6a5fcf67..a3447c296f 100644 --- a/tests/e2e/Ruby27Test.php +++ b/tests/e2e/Ruby27Test.php @@ -35,6 +35,8 @@ final class Ruby27Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Ruby30Test.php b/tests/e2e/Ruby30Test.php index c240bf5527..e9b5c42684 100644 --- a/tests/e2e/Ruby30Test.php +++ b/tests/e2e/Ruby30Test.php @@ -35,6 +35,8 @@ final class Ruby30Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Ruby31Test.php b/tests/e2e/Ruby31Test.php index 3786ac2705..8fe4de113a 100644 --- a/tests/e2e/Ruby31Test.php +++ b/tests/e2e/Ruby31Test.php @@ -35,6 +35,8 @@ final class Ruby31Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/Rust183Test.php b/tests/e2e/Rust183Test.php index 3f50a97664..394250ca65 100644 --- a/tests/e2e/Rust183Test.php +++ b/tests/e2e/Rust183Test.php @@ -31,6 +31,7 @@ final class Rust183Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSE, diff --git a/tests/e2e/Swift61Test.php b/tests/e2e/Swift61Test.php index a93b72b224..da635d52c4 100644 --- a/tests/e2e/Swift61Test.php +++ b/tests/e2e/Swift61Test.php @@ -37,13 +37,13 @@ final class Swift61Test extends Base ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, ...Base::ENUM_RESPONSES, ...Base::MODEL_RESPONSES, - ...Base::OBJECT_ARRAY_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, diff --git a/tests/e2e/Unity2021Test.php b/tests/e2e/Unity2021Test.php index c0f245f8ea..1e82c86d43 100644 --- a/tests/e2e/Unity2021Test.php +++ b/tests/e2e/Unity2021Test.php @@ -93,6 +93,7 @@ public function tearDown(): void ...Base::FOO_RESPONSES, ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::UPLOAD_RESPONSES, ...Base::DOWNLOAD_RESPONSES, diff --git a/tests/e2e/WebChromiumTest.php b/tests/e2e/WebChromiumTest.php index 3b87428f09..96935a17ec 100644 --- a/tests/e2e/WebChromiumTest.php +++ b/tests/e2e/WebChromiumTest.php @@ -41,6 +41,8 @@ final class WebChromiumTest extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::UPLOAD_RESPONSE, diff --git a/tests/e2e/WebNodeTest.php b/tests/e2e/WebNodeTest.php index 3440c51b5a..95cb94bf9d 100644 --- a/tests/e2e/WebNodeTest.php +++ b/tests/e2e/WebNodeTest.php @@ -42,6 +42,8 @@ final class WebNodeTest extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::ARRAY_PARAMETER_RESPONSES, + ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::ENUM_RESPONSES, diff --git a/tests/e2e/languages/android/Tests.kt b/tests/e2e/languages/android/Tests.kt index 0a452eff55..833d80bcc2 100644 --- a/tests/e2e/languages/android/Tests.kt +++ b/tests/e2e/languages/android/Tests.kt @@ -13,6 +13,7 @@ import io.appwrite.Operator import io.appwrite.Condition import io.appwrite.enums.MockType import io.appwrite.extensions.fromJson +import io.appwrite.extensions.toJsonWithNulls import io.appwrite.models.Error import io.appwrite.models.InputFile import io.appwrite.models.Mock @@ -181,6 +182,24 @@ class ServiceTest { val result = general.redirect() writeToFile((result as Map)["result"] as String) + writeToFile(general.listRows(listOf("not JSON", MockType.FIRST) as List).result) + writeToFile(general.createDocuments(listOf(mapOf("\$id" to "first")), listOf("ready")).result) + + // Erased generics let a cast pass non-string items; they are rejected before any request. + try { + general.listRows(listOf(mapOf("method" to "limit", "values" to listOf(1))) as List) + error("Invalid string list was accepted") + } catch (e: AppwriteException) { + writeToFile(mapOf("message" to e.message, "type" to e.type, "code" to e.code, "response" to e.response).toJsonWithNulls()) + } + try { + foo.post("string", 123, listOf(1) as List) + error("Invalid string list was accepted") + } catch (e: AppwriteException) { + writeToFile(mapOf("message" to e.message, "type" to e.type, "code" to e.code, "response" to e.response).toJsonWithNulls()) + } + writeToFile(general.createDocuments(listOf(mapOf("\$id" to "first")), listOf("ready", null) as List).result) + for ((id, plain) in listOf("" to "0", "0" to "")) { try { general.validatePath(plain, id) diff --git a/tests/e2e/languages/apple/Tests.swift b/tests/e2e/languages/apple/Tests.swift index fd68a57ea7..133318d2f0 100644 --- a/tests/e2e/languages/apple/Tests.swift +++ b/tests/e2e/languages/apple/Tests.swift @@ -139,6 +139,12 @@ class Tests: XCTestCase { let result = try await general.redirect() print((result as! [String: Any])["result"] as! String) + mock = try await general.listRows(queries: ["not JSON", MockType.first.rawValue]) + print(mock.result) + + mock = try await general.createDocuments(documents: [AnyCodable(["$id": "first"])], labels: ["ready"]) + print(mock.result) + for (id, plain) in [("", "0"), ("0", "")] { do { _ = try await general.validatePath(plain: plain, id: id) @@ -202,12 +208,6 @@ class Tests: XCTestCase { ]) print(mock.result) - mock = try await general.createDocuments(documents: [ - AnyCodable(["$id": "one", "title": "hello"]), - AnyCodable(["$id": "two", "title": "world"]) - ]) - print(mock.result) - do { try await general.error400() } catch let error as AppwriteError { diff --git a/tests/e2e/languages/dart/tests.dart b/tests/e2e/languages/dart/tests.dart index 3cbd915553..08e90bdd1a 100644 --- a/tests/e2e/languages/dart/tests.dart +++ b/tests/e2e/languages/dart/tests.dart @@ -67,6 +67,12 @@ void main() async { final res = await general.redirect(); print(res['result']); + response = await general.listRows(queries: ['not JSON', MockType.first.value]); + print(response.result); + + response = await general.createDocuments(documents: [{r'$id': 'first'}], labels: ['ready']); + print(response.result); + for (final ids in [['', '0'], ['0', '']]) { try { await general.validatePath(id: ids[0], plain: ids[1]); diff --git a/tests/e2e/languages/deno/tests.ts b/tests/e2e/languages/deno/tests.ts index af760f9890..ca986d9275 100644 --- a/tests/e2e/languages/deno/tests.ts +++ b/tests/e2e/languages/deno/tests.ts @@ -75,6 +75,35 @@ async function start() { // @ts-ignore console.log(response.result); + response = await general.listRows(["not JSON", appwrite.MockType.First]); + console.log(response.result); + + response = await general.createDocuments([{ $id: "first" }], ["ready"]); + console.log(response.result); + + // String-list items are checked against the schema before any request. + try { + // @ts-ignore + await general.listRows([{ method: "limit", values: [1] }]); + throw new Error("Invalid string list was accepted"); + } catch (error) { + if (!(error instanceof appwrite.AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + try { + // @ts-ignore + await foo.post("string", 123, [1]); + throw new Error("Invalid string list was accepted"); + } catch (error) { + if (!(error instanceof appwrite.AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + // @ts-ignore + response = await general.createDocuments([{ $id: "first" }], ["ready", null]); + console.log(response.result); + for (const [id, plain] of [["", "0"], ["0", ""]]) { try { await general.validatePath(plain, id); diff --git a/tests/e2e/languages/dotnet/Tests.cs b/tests/e2e/languages/dotnet/Tests.cs index 717bdfa196..ff7f8e768b 100644 --- a/tests/e2e/languages/dotnet/Tests.cs +++ b/tests/e2e/languages/dotnet/Tests.cs @@ -70,6 +70,16 @@ public async Task Test1() var result = await general.Redirect(); TestContext.WriteLine((result as Dictionary)["result"]); + // Array parameter tests + mock = await general.ListRows(new List { "not JSON", MockType.First.Value }); + TestContext.WriteLine(mock.Result); + + mock = await general.CreateDocuments( + documents: new List { new Dictionary { { "$id", "first" } } }, + labels: new List { "ready" } + ); + TestContext.WriteLine(mock.Result); + foreach (var ids in new[] { new[] { "", "0" }, new[] { "0", "" } }) { try diff --git a/tests/e2e/languages/flutter/tests.dart b/tests/e2e/languages/flutter/tests.dart index 2a1e10ec75..b73e077517 100644 --- a/tests/e2e/languages/flutter/tests.dart +++ b/tests/e2e/languages/flutter/tests.dart @@ -137,6 +137,12 @@ void main() async { final res = await general.redirect(); print(res['result']); + response = await general.listRows(queries: ['not JSON', MockType.first.value]); + print(response.result); + + response = await general.createDocuments(documents: [{r'$id': 'first'}], labels: ['ready']); + print(response.result); + for (final ids in [['', '0'], ['0', '']]) { try { await general.validatePath(id: ids[0], plain: ids[1]); diff --git a/tests/e2e/languages/go-v2/tests.go b/tests/e2e/languages/go-v2/tests.go index 96f5a51dc4..5a04e19c16 100644 --- a/tests/e2e/languages/go-v2/tests.go +++ b/tests/e2e/languages/go-v2/tests.go @@ -114,6 +114,21 @@ func testGeneralService(client client.Client, stringInArray []string) { } fmt.Printf("%s\n", (*response).(map[string]interface{})["result"].(string)) + listRowsResponse, err := general.ListRows(general.WithListRowsQueries([]string{"not JSON", "first"})) + if err != nil { + fmt.Printf("general.ListRows => error %v", err) + } + fmt.Printf("%s\n", listRowsResponse.Result) + + documentsResponse, err := general.CreateDocuments( + []interface{}{map[string]interface{}{"$id": "first"}}, + general.WithCreateDocumentsLabels([]string{"ready"}), + ) + if err != nil { + fmt.Printf("general.CreateDocuments => error %v", err) + } + fmt.Printf("%s\n", documentsResponse.Result) + for _, ids := range [][2]string{{"", "0"}, {"0", ""}} { if _, err := general.ValidatePath(ids[1], ids[0]); err != nil { fmt.Println(err) diff --git a/tests/e2e/languages/go/tests.go b/tests/e2e/languages/go/tests.go index d729c468e3..de6581be2f 100644 --- a/tests/e2e/languages/go/tests.go +++ b/tests/e2e/languages/go/tests.go @@ -114,6 +114,21 @@ func testGeneralService(client client.Client, stringInArray []string) { } fmt.Printf("%s\n", (*response).(map[string]interface{})["result"].(string)) + listRowsResponse, err := general.ListRows(general.WithListRowsQueries([]string{"not JSON", "first"})) + if err != nil { + fmt.Printf("general.ListRows => error %v", err) + } + fmt.Printf("%s\n", listRowsResponse.Result) + + documentsResponse, err := general.CreateDocuments( + []interface{}{map[string]interface{}{"$id": "first"}}, + general.WithCreateDocumentsLabels([]string{"ready"}), + ) + if err != nil { + fmt.Printf("general.CreateDocuments => error %v", err) + } + fmt.Printf("%s\n", documentsResponse.Result) + for _, ids := range [][2]string{{"", "0"}, {"0", ""}} { if _, err := general.ValidatePath(ids[1], ids[0]); err != nil { fmt.Println(err) diff --git a/tests/e2e/languages/kotlin/Tests.kt b/tests/e2e/languages/kotlin/Tests.kt index f512c28507..96454b7880 100644 --- a/tests/e2e/languages/kotlin/Tests.kt +++ b/tests/e2e/languages/kotlin/Tests.kt @@ -10,6 +10,7 @@ import io.appwrite.Condition import io.appwrite.enums.MockType import io.appwrite.exceptions.AppwriteException import io.appwrite.extensions.fromJson +import io.appwrite.extensions.toJsonWithNulls import io.appwrite.models.Error import io.appwrite.models.InputFile import io.appwrite.models.Mock @@ -101,6 +102,24 @@ class ServiceTest { val result = general.redirect() writeToFile((result as Map)["result"] as String) + writeToFile(general.listRows(listOf("not JSON", MockType.FIRST) as List).result) + writeToFile(general.createDocuments(listOf(mapOf("\$id" to "first")), listOf("ready")).result) + + // Erased generics let a cast pass non-string items; they are rejected before any request. + try { + general.listRows(listOf(mapOf("method" to "limit", "values" to listOf(1))) as List) + error("Invalid string list was accepted") + } catch (e: AppwriteException) { + writeToFile(mapOf("message" to e.message, "type" to e.type, "code" to e.code, "response" to e.response).toJsonWithNulls()) + } + try { + foo.post("string", 123, listOf(1) as List) + error("Invalid string list was accepted") + } catch (e: AppwriteException) { + writeToFile(mapOf("message" to e.message, "type" to e.type, "code" to e.code, "response" to e.response).toJsonWithNulls()) + } + writeToFile(general.createDocuments(listOf(mapOf("\$id" to "first")), listOf("ready", null) as List).result) + for ((id, plain) in listOf("" to "0", "0" to "")) { try { general.validatePath(plain, id) diff --git a/tests/e2e/languages/node/test.js b/tests/e2e/languages/node/test.js index e79f3ff513..b5d819c3ed 100644 --- a/tests/e2e/languages/node/test.js +++ b/tests/e2e/languages/node/test.js @@ -146,6 +146,32 @@ async function start() { response = await general.redirect(); console.log(response.result); + response = await general.listRows(['not JSON', MockType.First]); + console.log(response.result); + + response = await general.createDocuments([{ $id: 'first' }], ['ready']); + console.log(response.result); + + // String-list items are checked against the schema before any request. + try { + await general.listRows([{ method: 'limit', values: [1] }]); + throw new Error('Invalid string list was accepted'); + } catch (error) { + if (!(error instanceof AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + try { + await foo.post('string', 123, [1]); + throw new Error('Invalid string list was accepted'); + } catch (error) { + if (!(error instanceof AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + response = await general.createDocuments([{ $id: 'first' }], ['ready', null]); + console.log(response.result); + for (const [id, plain] of [['', '0'], ['0', '']]) { try { await general.validatePath({ id, plain }); diff --git a/tests/e2e/languages/php/test.php b/tests/e2e/languages/php/test.php index 23481eec3d..b6cd497ff1 100644 --- a/tests/e2e/languages/php/test.php +++ b/tests/e2e/languages/php/test.php @@ -172,6 +172,26 @@ public function toArray(): array $response = $general->redirect(); echo $response['result'] . "\n"; +echo $general->listRows(['not JSON', MockType::FIRST()])->result . "\n"; +echo $general->createDocuments([['$id' => 'first']], ['ready'])->result . "\n"; + +// String-list items are checked against the schema before any request. +try { + $general->listRows([['method' => 'limit', 'values' => [1]]]); + throw new RuntimeException('Invalid string list was accepted'); +} catch (AppwriteException $error) { + echo json_encode(['message' => $error->getMessage(), 'type' => $error->getType(), 'code' => $error->getCode(), 'response' => $error->getResponse()], JSON_THROW_ON_ERROR) . "\n"; +} + +try { + $foo->post('string', 123, [1]); + throw new RuntimeException('Invalid string list was accepted'); +} catch (AppwriteException $error) { + echo json_encode(['message' => $error->getMessage(), 'type' => $error->getType(), 'code' => $error->getCode(), 'response' => $error->getResponse()], JSON_THROW_ON_ERROR) . "\n"; +} + +echo $general->createDocuments([['$id' => 'first']], ['ready', null])->result . "\n"; + foreach ([['', '0'], ['0', '']] as [$id, $plain]) { try { $general->validatePath($plain, $id); diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index d089714be6..ea588cf460 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -67,6 +67,9 @@ response = general.redirect() print(response['result']) +print(general.list_rows(['not JSON', MockType.FIRST]).result) +print(general.create_documents([{'$id': 'first'}], labels=['ready']).result) + # String-list items are checked against the schema before any request. try: general.list_rows([{'method': 'limit', 'values': [1]}]) @@ -80,8 +83,7 @@ except AppwriteException as error: print(json.dumps({'message': error.message, 'type': error.type, 'code': error.code, 'response': error.response})) -print(general.list_rows(['not JSON', MockType.FIRST]).result) -print(json.dumps(general.create_documents([{'$id': 'first'}], labels=['ready', None]).to_dict())) +print(general.create_documents([{'$id': 'first'}], labels=['ready', None]).result) # Nested lists serialize by index, so raw calls reach API validation. try: @@ -102,7 +104,7 @@ 'file': InputFile.from_bytes(b'fixture', 'fixture.txt', 'text/plain'), }, ) -print(json.dumps(response)) +print(response['result']) for id, plain in [('', '0'), ('0', '')]: try: diff --git a/tests/e2e/languages/react-native/browser.js b/tests/e2e/languages/react-native/browser.js index 22162aa277..72ded89409 100644 --- a/tests/e2e/languages/react-native/browser.js +++ b/tests/e2e/languages/react-native/browser.js @@ -127,6 +127,32 @@ import { response = await general.redirect(); console.log(response.result); + response = await general.listRows(['not JSON', MockType.First]); + console.log(response.result); + + response = await general.createDocuments([{ $id: 'first' }], ['ready']); + console.log(response.result); + + // String-list items are checked against the schema before any request. + try { + await general.listRows([{ method: 'limit', values: [1] }]); + throw new Error('Invalid string list was accepted'); + } catch (error) { + if (!(error instanceof AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + try { + await foo.post('string', 123, [1]); + throw new Error('Invalid string list was accepted'); + } catch (error) { + if (!(error instanceof AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + response = await general.createDocuments([{ $id: 'first' }], ['ready', null]); + console.log(response.result); + for (const [id, plain] of [['', '0'], ['0', '']]) { try { await general.validatePath({ id, plain }); diff --git a/tests/e2e/languages/ruby/tests.rb b/tests/e2e/languages/ruby/tests.rb index 6dd975900b..928709a78e 100644 --- a/tests/e2e/languages/ruby/tests.rb +++ b/tests/e2e/languages/ruby/tests.rb @@ -56,6 +56,26 @@ response = general.redirect() puts response["result"] +puts general.list_rows(queries: ['not JSON', MockType::FIRST]).result +puts general.create_documents(documents: [{'$id': 'first'}], labels: ['ready']).result + +# String-list items are checked against the schema before any request. +begin + general.list_rows(queries: [{method: 'limit', values: [1]}]) + raise 'Invalid string list was accepted' +rescue Appwrite::Exception => error + puts({message: error.message, type: error.type, code: error.code, response: error.response}.to_json) +end + +begin + foo.post(x: 'string', y: 123, z: [1]) + raise 'Invalid string list was accepted' +rescue Appwrite::Exception => error + puts({message: error.message, type: error.type, code: error.code, response: error.response}.to_json) +end + +puts general.create_documents(documents: [{'$id': 'first'}], labels: ['ready', nil]).result + [['', '0'], ['0', '']].each do |id, plain| begin general.validate_path(id: id, plain: plain) diff --git a/tests/e2e/languages/swift/Tests.swift b/tests/e2e/languages/swift/Tests.swift index 0fa927654e..84df879a44 100644 --- a/tests/e2e/languages/swift/Tests.swift +++ b/tests/e2e/languages/swift/Tests.swift @@ -5,6 +5,7 @@ import FoundationNetworking #endif import Appwrite import JSONCodable +import AppwriteEnums import AsyncHTTPClient import NIO @@ -80,6 +81,12 @@ class Tests: XCTestCase { let result = try await general.redirect() print((result as! [String: Any])["result"] as! String) + mock = try await general.listRows(queries: ["not JSON", MockType.first.rawValue]) + print(mock.result) + + mock = try await general.createDocuments(documents: [AnyCodable(["$id": "first"])], labels: ["ready"]) + print(mock.result) + for (id, plain) in [("", "0"), ("0", "")] { do { _ = try await general.validatePath(plain: plain, id: id) @@ -143,12 +150,6 @@ class Tests: XCTestCase { ]) print(mock.result) - mock = try await general.createDocuments(documents: [ - AnyCodable(["$id": "one", "title": "hello"]), - AnyCodable(["$id": "two", "title": "world"]) - ]) - print(mock.result) - do { try await general.error400() } catch let error as AppwriteError { diff --git a/tests/e2e/languages/unity/Tests.cs b/tests/e2e/languages/unity/Tests.cs index 961c04019a..3e78a86772 100644 --- a/tests/e2e/languages/unity/Tests.cs +++ b/tests/e2e/languages/unity/Tests.cs @@ -158,6 +158,16 @@ private async Task RunAsyncTest() var result = await general.Redirect(); LogResult((result as Dictionary)["result"]); + // Array parameter tests + mock = await general.ListRows(new List { "not JSON", MockType.First.Value }); + LogResult(mock.Result); + + mock = await general.CreateDocuments( + documents: new List { new Dictionary { { "$id", "first" } } }, + labels: new List { "ready" } + ); + LogResult(mock.Result); + foreach (var ids in new[] { new[] { "", "0" }, new[] { "0", "" } }) { try diff --git a/tests/e2e/languages/web/index.html b/tests/e2e/languages/web/index.html index ebcc1ff538..c12869d8c3 100644 --- a/tests/e2e/languages/web/index.html +++ b/tests/e2e/languages/web/index.html @@ -195,6 +195,32 @@ response = await general.redirect(); console.log(response.result); + response = await general.listRows(['not JSON', MockType.First]); + console.log(response.result); + + response = await general.createDocuments([{ $id: 'first' }], ['ready']); + console.log(response.result); + + // String-list items are checked against the schema before any request. + try { + await general.listRows([{ method: 'limit', values: [1] }]); + throw new Error('Invalid string list was accepted'); + } catch (error) { + if (!(error instanceof Appwrite.AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + try { + await foo.post('string', 123, [1]); + throw new Error('Invalid string list was accepted'); + } catch (error) { + if (!(error instanceof Appwrite.AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + response = await general.createDocuments([{ $id: 'first' }], ['ready', null]); + console.log(response.result); + for (const [id, plain] of [['', '0'], ['0', '']]) { try { await general.validatePath({ id, plain }); diff --git a/tests/e2e/languages/web/node.js b/tests/e2e/languages/web/node.js index b8b3b30a15..0ae7e10788 100644 --- a/tests/e2e/languages/web/node.js +++ b/tests/e2e/languages/web/node.js @@ -124,6 +124,32 @@ async function start() { response = await general.redirect(); console.log(response.result); + response = await general.listRows(['not JSON', MockType.First]); + console.log(response.result); + + response = await general.createDocuments([{ $id: 'first' }], ['ready']); + console.log(response.result); + + // String-list items are checked against the schema before any request. + try { + await general.listRows([{ method: 'limit', values: [1] }]); + throw new Error('Invalid string list was accepted'); + } catch (error) { + if (!(error instanceof AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + try { + await foo.post('string', 123, [1]); + throw new Error('Invalid string list was accepted'); + } catch (error) { + if (!(error instanceof AppwriteException)) throw error; + console.log(JSON.stringify({ message: error.message, type: error.type, code: error.code, response: error.response })); + } + + response = await general.createDocuments([{ $id: 'first' }], ['ready', null]); + console.log(response.result); + for (const [id, plain] of [['', '0'], ['0', '']]) { try { await general.validatePath({ id, plain }); From 07c130358027fa52eea1c301f719f49d10fe438b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 11 Sep 2026 18:55:35 +0530 Subject: [PATCH 7/7] fix: leave Kotlin and Android string lists to the type system Their List parameters only take other items through unchecked casts, the same boundary as Swift, Dart and .NET, so drop the generated check and assert only the pass-through lines. --- .../src/main/java/io/package/Service.kt.twig | 11 +---------- .../java/io/package/services/Service.kt.twig | 5 ----- .../io/appwrite/services/Service.kt.twig | 11 +---------- .../appwrite/services/ServiceTemplate.kt.twig | 5 ----- tests/e2e/Android16Java17Test.php | 1 - tests/e2e/Android5Java17Test.php | 1 - tests/e2e/KotlinJava17Test.php | 1 - tests/e2e/languages/android/Tests.kt | 18 +----------------- tests/e2e/languages/kotlin/Tests.kt | 18 +----------------- 9 files changed, 4 insertions(+), 67 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/Service.kt.twig b/templates/android/library/src/main/java/io/package/Service.kt.twig index 8b8e1b8302..332fc958ab 100644 --- a/templates/android/library/src/main/java/io/package/Service.kt.twig +++ b/templates/android/library/src/main/java/io/package/Service.kt.twig @@ -1,19 +1,10 @@ package {{ sdk.namespace | caseDot }} import {{ sdk.namespace | caseDot }}.Client -import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.info.title | caseUcfirst }}Exception /** * Abstract class for services. * * @param client The Appwrite client. */ -abstract class Service(val client: Client) { - // Erased generics let other item types through casts and Java callers. - // Enum items are allowed because they serialize to their string value. - protected fun validateStringList(name: String, value: List<*>?, nullableItems: Boolean = false) { - if (value?.any { it !is String && it !is Enum<*> && !(nullableItems && it == null) } == true) { - throw {{ spec.info.title | caseUcfirst }}Exception("Invalid parameter: \"$name\" must be a list of strings.", 0, "sdk_input_validation") - } - } -} +abstract class Service(val client: Client) diff --git a/templates/android/library/src/main/java/io/package/services/Service.kt.twig b/templates/android/library/src/main/java/io/package/services/Service.kt.twig index ce5153442b..c9598b2f21 100644 --- a/templates/android/library/src/main/java/io/package/services/Service.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Service.kt.twig @@ -79,11 +79,6 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { throw {{ spec.info.title | caseUcfirst }}Exception("Missing required parameter: \"{{ parameter.name | caseCamel }}\"") } {%~ endif %} - {%~ endfor %} - {%~ for parameter in allParameters %} - {%~ if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} - validateStringList("{{ parameter.name | caseCamel }}", {{ parameter.name | caseCamel }}{% if (parameter | arraySchema) | schemaNullable %}, nullableItems = true{% endif %}) - {%~ endif %} {%~ endfor %} val apiPath = {% if pathParameters is empty and securityQueries is empty %}"{{ method.path }}"{% else %}("{{ method.path }}" {%~ for parameter in pathParameters %} diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig index 27c64f108d..a6b5623fc9 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig @@ -1,14 +1,5 @@ package {{ sdk.namespace | caseDot }}.services import {{ sdk.namespace | caseDot }}.Client -import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.info.title | caseUcfirst }}Exception -abstract class Service(val client: Client) { - // Erased generics let other item types through casts and Java callers. - // Enum items are allowed because they serialize to their string value. - protected fun validateStringList(name: String, value: List<*>?, nullableItems: Boolean = false) { - if (value?.any { it !is String && it !is Enum<*> && !(nullableItems && it == null) } == true) { - throw {{ spec.info.title | caseUcfirst }}Exception("Invalid parameter: \"$name\" must be a list of strings.", 0, "sdk_input_validation") - } - } -} +abstract class Service(val client: Client) diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig index 29b63a4e27..2385578d35 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig @@ -70,11 +70,6 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { throw {{ spec.info.title | caseUcfirst }}Exception("Missing required parameter: \"{{ parameter.name | caseCamel }}\"") } {%~ endif %} - {%~ endfor %} - {%~ for parameter in allParameters %} - {%~ if (parameter | schemaType) == 'array' and ((parameter | arraySchema) | schemaType) == 'string' %} - validateStringList("{{ parameter.name | caseCamel }}", {{ parameter.name | caseCamel }}{% if (parameter | arraySchema) | schemaNullable %}, nullableItems = true{% endif %}) - {%~ endif %} {%~ endfor %} val apiPath = {% if pathParameters is empty and securityQueries is empty %}"{{ method.path }}"{% else %}("{{ method.path }}" {%~ for parameter in pathParameters %} diff --git a/tests/e2e/Android16Java17Test.php b/tests/e2e/Android16Java17Test.php index 329fe21d06..806875b006 100644 --- a/tests/e2e/Android16Java17Test.php +++ b/tests/e2e/Android16Java17Test.php @@ -39,7 +39,6 @@ final class Android16Java17Test extends Base ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, ...Base::ARRAY_PARAMETER_RESPONSES, - ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/Android5Java17Test.php b/tests/e2e/Android5Java17Test.php index 2cd22e5d14..fe1f1f9f26 100644 --- a/tests/e2e/Android5Java17Test.php +++ b/tests/e2e/Android5Java17Test.php @@ -39,7 +39,6 @@ final class Android5Java17Test extends Base ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, ...Base::ARRAY_PARAMETER_RESPONSES, - ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/KotlinJava17Test.php b/tests/e2e/KotlinJava17Test.php index f82519b7b7..cd8545376f 100644 --- a/tests/e2e/KotlinJava17Test.php +++ b/tests/e2e/KotlinJava17Test.php @@ -39,7 +39,6 @@ final class KotlinJava17Test extends Base ...Base::BAR_RESPONSES, ...Base::GENERAL_RESPONSES, ...Base::ARRAY_PARAMETER_RESPONSES, - ...Base::STRING_LIST_VALIDATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::NULL_PATH_RESPONSE, ...Base::UPLOAD_RESPONSES, diff --git a/tests/e2e/languages/android/Tests.kt b/tests/e2e/languages/android/Tests.kt index 833d80bcc2..81d3641228 100644 --- a/tests/e2e/languages/android/Tests.kt +++ b/tests/e2e/languages/android/Tests.kt @@ -13,7 +13,6 @@ import io.appwrite.Operator import io.appwrite.Condition import io.appwrite.enums.MockType import io.appwrite.extensions.fromJson -import io.appwrite.extensions.toJsonWithNulls import io.appwrite.models.Error import io.appwrite.models.InputFile import io.appwrite.models.Mock @@ -182,24 +181,9 @@ class ServiceTest { val result = general.redirect() writeToFile((result as Map)["result"] as String) - writeToFile(general.listRows(listOf("not JSON", MockType.FIRST) as List).result) + writeToFile(general.listRows(listOf("not JSON", MockType.FIRST.value)).result) writeToFile(general.createDocuments(listOf(mapOf("\$id" to "first")), listOf("ready")).result) - // Erased generics let a cast pass non-string items; they are rejected before any request. - try { - general.listRows(listOf(mapOf("method" to "limit", "values" to listOf(1))) as List) - error("Invalid string list was accepted") - } catch (e: AppwriteException) { - writeToFile(mapOf("message" to e.message, "type" to e.type, "code" to e.code, "response" to e.response).toJsonWithNulls()) - } - try { - foo.post("string", 123, listOf(1) as List) - error("Invalid string list was accepted") - } catch (e: AppwriteException) { - writeToFile(mapOf("message" to e.message, "type" to e.type, "code" to e.code, "response" to e.response).toJsonWithNulls()) - } - writeToFile(general.createDocuments(listOf(mapOf("\$id" to "first")), listOf("ready", null) as List).result) - for ((id, plain) in listOf("" to "0", "0" to "")) { try { general.validatePath(plain, id) diff --git a/tests/e2e/languages/kotlin/Tests.kt b/tests/e2e/languages/kotlin/Tests.kt index 96454b7880..c680a4af62 100644 --- a/tests/e2e/languages/kotlin/Tests.kt +++ b/tests/e2e/languages/kotlin/Tests.kt @@ -10,7 +10,6 @@ import io.appwrite.Condition import io.appwrite.enums.MockType import io.appwrite.exceptions.AppwriteException import io.appwrite.extensions.fromJson -import io.appwrite.extensions.toJsonWithNulls import io.appwrite.models.Error import io.appwrite.models.InputFile import io.appwrite.models.Mock @@ -102,24 +101,9 @@ class ServiceTest { val result = general.redirect() writeToFile((result as Map)["result"] as String) - writeToFile(general.listRows(listOf("not JSON", MockType.FIRST) as List).result) + writeToFile(general.listRows(listOf("not JSON", MockType.FIRST.value)).result) writeToFile(general.createDocuments(listOf(mapOf("\$id" to "first")), listOf("ready")).result) - // Erased generics let a cast pass non-string items; they are rejected before any request. - try { - general.listRows(listOf(mapOf("method" to "limit", "values" to listOf(1))) as List) - error("Invalid string list was accepted") - } catch (e: AppwriteException) { - writeToFile(mapOf("message" to e.message, "type" to e.type, "code" to e.code, "response" to e.response).toJsonWithNulls()) - } - try { - foo.post("string", 123, listOf(1) as List) - error("Invalid string list was accepted") - } catch (e: AppwriteException) { - writeToFile(mapOf("message" to e.message, "type" to e.type, "code" to e.code, "response" to e.response).toJsonWithNulls()) - } - writeToFile(general.createDocuments(listOf(mapOf("\$id" to "first")), listOf("ready", null) as List).result) - for ((id, plain) in listOf("" to "0", "0" to "")) { try { general.validatePath(plain, id)