diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37d870a9b..e487448c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,10 +24,9 @@ jobs: Deno1303, DotNet60, DotNet80, - FlutterStable, FlutterBeta, - Go112, - Go118, + FlutterStable, + Go122, KotlinJava8, KotlinJava11, KotlinJava17, @@ -101,3 +100,23 @@ jobs: - name: Lint run: composer lint + + specs: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP with PECL extension + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: curl + + - name: Install + run: composer install + + - name: Validate specs + run: composer test tests/SpecsTest.php + \ No newline at end of file diff --git a/composer.json b/composer.json index 2f2ee18f1..e0b8eb2ff 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "ext-mbstring": "*", "ext-json": "*", "twig/twig": "3.14.*", - "matthiasmullie/minify": "1.3.*" + "matthiasmullie/minify": "1.3.*", + "utopia-php/fetch": "^0.2.1" }, "require-dev": { "phpunit/phpunit": "11.*", diff --git a/composer.lock b/composer.lock index a8522b150..74e116a1e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9283e0faa88dc724e482a15d92771eb7", + "content-hash": "542c1fbb222c159cfc8e1cfb32d8f757", "packages": [ { "name": "matthiasmullie/minify", @@ -510,21 +510,60 @@ } ], "time": "2024-09-09T17:55:12+00:00" + }, + { + "name": "utopia-php/fetch", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/fetch.git", + "reference": "1423c0ee3eef944d816ca6e31706895b585aea82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/fetch/zipball/1423c0ee3eef944d816ca6e31706895b585aea82", + "reference": "1423c0ee3eef944d816ca6e31706895b585aea82", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "^1.5.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Fetch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library that provides an interface for making HTTP Requests.", + "support": { + "issues": "https://github.com/utopia-php/fetch/issues", + "source": "https://github.com/utopia-php/fetch/tree/0.2.1" + }, + "time": "2024-03-18T11:50:59+00:00" } ], "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.5.4", + "version": "v7.5.5", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "c490591cc9c2f4830633b905547d30d5eb609c88" + "reference": "f29c7d671afc5c4e1140bd7b9f2749e827902a1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/c490591cc9c2f4830633b905547d30d5eb609c88", - "reference": "c490591cc9c2f4830633b905547d30d5eb609c88", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f29c7d671afc5c4e1140bd7b9f2749e827902a1e", + "reference": "f29c7d671afc5c4e1140bd7b9f2749e827902a1e", "shasum": "" }, "require": { @@ -538,7 +577,7 @@ "phpunit/php-code-coverage": "^11.0.6", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.3.3", + "phpunit/phpunit": "^11.3.6", "sebastian/environment": "^7.2.0", "symfony/console": "^6.4.11 || ^7.1.4", "symfony/process": "^6.4.8 || ^7.1.3" @@ -548,11 +587,11 @@ "ext-pcov": "*", "ext-posix": "*", "infection/infection": "^0.29.6", - "phpstan/phpstan": "^1.12.1", - "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan": "^1.12.4", + "phpstan/phpstan-deprecation-rules": "^1.2.1", "phpstan/phpstan-phpunit": "^1.4.0", "phpstan/phpstan-strict-rules": "^1.6.0", - "squizlabs/php_codesniffer": "^3.10.2", + "squizlabs/php_codesniffer": "^3.10.3", "symfony/filesystem": "^6.4.9 || ^7.1.2" }, "bin": [ @@ -593,7 +632,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.5.4" + "source": "https://github.com/paratestphp/paratest/tree/v7.5.5" }, "funding": [ { @@ -605,7 +644,7 @@ "type": "paypal" } ], - "time": "2024-09-04T21:15:27+00:00" + "time": "2024-09-20T12:57:46+00:00" }, { "name": "fidry/cpu-core-counter", @@ -789,16 +828,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.1.0", + "version": "v5.2.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1" + "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/683130c2ff8c2739f4822ff7ac5c873ec529abd1", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb", + "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb", "shasum": "" }, "require": { @@ -841,9 +880,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.1.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.2.0" }, - "time": "2024-07-01T20:03:41+00:00" + "time": "2024-09-15T16:40:33+00:00" }, { "name": "phar-io/manifest", @@ -1288,16 +1327,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.3.4", + "version": "11.3.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d2ef57db1410b102b250e0cdce6675a60c2a993d" + "reference": "d62c45a19c665bb872c2a47023a0baf41a98bb2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d2ef57db1410b102b250e0cdce6675a60c2a993d", - "reference": "d2ef57db1410b102b250e0cdce6675a60c2a993d", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d62c45a19c665bb872c2a47023a0baf41a98bb2b", + "reference": "d62c45a19c665bb872c2a47023a0baf41a98bb2b", "shasum": "" }, "require": { @@ -1318,13 +1357,13 @@ "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.1", - "sebastian/comparator": "^6.0.2", + "sebastian/comparator": "^6.1.0", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.0", "sebastian/exporter": "^6.1.3", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", - "sebastian/type": "^5.0.1", + "sebastian/type": "^5.1.0", "sebastian/version": "^5.0.1" }, "suggest": { @@ -1368,7 +1407,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.3.4" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.3.6" }, "funding": [ { @@ -1384,7 +1423,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T06:08:34+00:00" + "time": "2024-09-19T10:54:28+00:00" }, { "name": "psr/container", @@ -1611,16 +1650,16 @@ }, { "name": "sebastian/comparator", - "version": "6.0.2", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "450d8f237bd611c45b5acf0733ce43e6bb280f81" + "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/450d8f237bd611c45b5acf0733ce43e6bb280f81", - "reference": "450d8f237bd611c45b5acf0733ce43e6bb280f81", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa37b9e2ca618cb051d71b60120952ee8ca8b03d", + "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d", "shasum": "" }, "require": { @@ -1631,12 +1670,12 @@ "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "6.1-dev" } }, "autoload": { @@ -1676,7 +1715,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.1.0" }, "funding": [ { @@ -1684,7 +1723,7 @@ "type": "github" } ], - "time": "2024-08-12T06:07:25+00:00" + "time": "2024-09-11T15:42:56+00:00" }, { "name": "sebastian/complexity", @@ -2253,28 +2292,28 @@ }, { "name": "sebastian/type", - "version": "5.0.1", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fb6a6566f9589e86661291d13eba708cce5eb4aa" + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb6a6566f9589e86661291d13eba708cce5eb4aa", - "reference": "fb6a6566f9589e86661291d13eba708cce5eb4aa", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac", + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -2298,7 +2337,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/type/tree/5.1.0" }, "funding": [ { @@ -2306,7 +2345,7 @@ "type": "github" } ], - "time": "2024-07-03T05:11:49+00:00" + "time": "2024-09-17T13:12:04+00:00" }, { "name": "sebastian/version", @@ -2364,16 +2403,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.2", + "version": "3.10.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017" + "reference": "62d32998e820bddc40f99f8251958aed187a5c9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/86e5f5dd9a840c46810ebe5ff1885581c42a3017", - "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c", + "reference": "62d32998e820bddc40f99f8251958aed187a5c9c", "shasum": "" }, "require": { @@ -2440,20 +2479,20 @@ "type": "open_collective" } ], - "time": "2024-07-21T23:26:44+00:00" + "time": "2024-09-18T10:38:58+00:00" }, { "name": "symfony/console", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "1eed7af6961d763e7832e874d7f9b21c3ea9c111" + "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/1eed7af6961d763e7832e874d7f9b21c3ea9c111", - "reference": "1eed7af6961d763e7832e874d7f9b21c3ea9c111", + "url": "https://api.github.com/repos/symfony/console/zipball/0fa539d12b3ccf068a722bbbffa07ca7079af9ee", + "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee", "shasum": "" }, "require": { @@ -2517,7 +2556,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.1.4" + "source": "https://github.com/symfony/console/tree/v7.1.5" }, "funding": [ { @@ -2533,7 +2572,7 @@ "type": "tidelift" } ], - "time": "2024-08-15T22:48:53+00:00" + "time": "2024-09-20T08:28:38+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -2696,16 +2735,16 @@ }, { "name": "symfony/process", - "version": "v7.1.3", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca" + "reference": "5c03ee6369281177f07f7c68252a280beccba847" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7f2f542c668ad6c313dc4a5e9c3321f733197eca", - "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca", + "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847", + "reference": "5c03ee6369281177f07f7c68252a280beccba847", "shasum": "" }, "require": { @@ -2737,7 +2776,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.1.3" + "source": "https://github.com/symfony/process/tree/v7.1.5" }, "funding": [ { @@ -2753,7 +2792,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:44:47+00:00" + "time": "2024-09-19T21:48:23+00:00" }, { "name": "symfony/service-contracts", @@ -2840,16 +2879,16 @@ }, { "name": "symfony/string", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6cd670a6d968eaeb1c77c2e76091c45c56bc367b" + "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6cd670a6d968eaeb1c77c2e76091c45c56bc367b", - "reference": "6cd670a6d968eaeb1c77c2e76091c45c56bc367b", + "url": "https://api.github.com/repos/symfony/string/zipball/d66f9c343fa894ec2037cc928381df90a7ad4306", + "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306", "shasum": "" }, "require": { @@ -2907,7 +2946,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.1.4" + "source": "https://github.com/symfony/string/tree/v7.1.5" }, "funding": [ { @@ -2923,7 +2962,7 @@ "type": "tidelift" } ], - "time": "2024-08-12T09:59:40+00:00" + "time": "2024-09-20T08:28:38+00:00" }, { "name": "theseer/tokenizer", diff --git a/mock-server/Dockerfile b/mock-server/Dockerfile index a1c3ed8aa..f048d39a4 100644 --- a/mock-server/Dockerfile +++ b/mock-server/Dockerfile @@ -30,6 +30,7 @@ COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor # Add Source Code COPY ./src /usr/src/code/src COPY ./app /usr/src/code/app +COPY ./resources /usr/src/code/resources EXPOSE 80 diff --git a/mock-server/app/http.php b/mock-server/app/http.php index c0397697e..646615dab 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -8,7 +8,6 @@ use Swoole\Constant; use Utopia\App; -use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\MockServer\Utopia\Exception; use Utopia\MockServer\Utopia\File; @@ -17,7 +16,6 @@ use Utopia\CLI\Console; use Utopia\MockServer\Utopia\Response; use Utopia\Swoole\Request; -use Utopia\Swoole\Response as UtopiaSwooleResponse; use Utopia\Validator\Text; use Utopia\Validator\Integer; use Utopia\Validator\ArrayList; @@ -63,7 +61,7 @@ ->label('sdk.response.code', Response::STATUS_CODE_OK) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->inject('response') - ->action(function (UtopiaSwooleResponse $response) { + ->action(function (Response $response) { $response->json([ 'version' => '1.0.0' ]); }); @@ -282,7 +280,7 @@ ->label('sdk.mock', true) ->inject('request') ->inject('response') - ->action(function (Request $request, UtopiaSwooleResponse $response) { + ->action(function (Request $request, Response $response) { $res = [ 'x-sdk-name' => $request->getHeader('x-sdk-name'), 'x-sdk-platform' => $request->getHeader('x-sdk-platform'), @@ -310,7 +308,7 @@ ->label('sdk.response.code', Response::STATUS_CODE_OK) ->label('sdk.mock', true) ->inject('response') - ->action(function (UtopiaSwooleResponse $response) { + ->action(function (Response $response) { $response ->setContentType('text/plain') @@ -339,14 +337,25 @@ ->param('file', [], new File(), 'Sample file param', skipValidation: true) ->inject('request') ->inject('response') - ->action(function (string $x, int $y, array $z, mixed $file, Request $request, UtopiaSwooleResponse $response) { - + ->action(function (string $x, int $y, array $z, mixed $file, Request $request, Response $response) { $file = $request->getFiles('file'); $contentRange = $request->getHeader('content-range'); $chunkSize = 5 * 1024 * 1024; // 5MB + if ($x != 'string') { + throw new Exception(Exception::GENERAL_MOCK, 'Wrong string value: ' . $x . ', expected: string'); + } + + if ($y !== 123) { + throw new Exception(Exception::GENERAL_MOCK, 'Wrong numeric value: ' . $y . ', expected: 123'); + } + + if ($z[0] !== 'string in array' || \count($z) !== 1) { + throw new Exception(Exception::GENERAL_MOCK, 'Wrong array value: ' . \json_encode($z) . ', expected: ["string in array"]'); + } + if (!empty($contentRange)) { $start = $request->getContentRangeStart(); $end = $request->getContentRangeEnd(); @@ -395,19 +404,79 @@ $file['size'] = (\is_array($file['size'])) ? $file['size'][0] : $file['size']; if ($file['name'] !== 'file.png') { - throw new Exception(Exception::GENERAL_MOCK, 'Wrong file name'); + throw new Exception(Exception::GENERAL_MOCK, 'Wrong file name: ' . $file['name'] . ', expected: file.png'); } if ($file['size'] !== 38756) { - throw new Exception(Exception::GENERAL_MOCK, 'Wrong file size'); + throw new Exception(Exception::GENERAL_MOCK, 'Wrong file size: ' . $file['size'] . ', expected: 38756'); } - if (\md5(\file_get_contents($file['tmp_name'])) !== 'd80e7e6999a3eb2ae0d631a96fe135a4') { - throw new Exception(Exception::GENERAL_MOCK, 'Wrong file uploaded'); + $hash = \md5(\file_get_contents($file['tmp_name'])); + if ($hash !== 'd80e7e6999a3eb2ae0d631a96fe135a4') { + throw new Exception(Exception::GENERAL_MOCK, 'Wrong file uploaded: ' . $hash . ', expected: d80e7e6999a3eb2ae0d631a96fe135a4'); } } }); +App::get('/v1/mock/tests/general/multipart') + ->alias('/v1/mock/tests/general/multipart-compiled') + ->desc('Multipart') + ->groups(['mock']) + ->label('scope', 'public') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'general') + ->label('sdk.method', 'multipart') + ->label('sdk.description', 'Mock a multipart request.') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_MULTIPART) + ->label('sdk.response.model', Response::MODEL_MULTIPART) + ->label('sdk.mock', true) + ->inject('response') + ->action(function (Response $response) { + $file = \file_get_contents(\getcwd() . '/resources/file.png'); + + $response->multipart([ + 'x' => 'abc', + 'y' => 123, + 'responseBody' => $file, + ]); + }); + +App::post('/v1/mock/tests/general/multipart-echo') + ->desc('Multipart echo') + ->groups(['mock']) + ->label('scope', 'public') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'general') + ->label('sdk.method', 'multipartEcho') + ->label('sdk.description', 'Echo a multipart request.') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_MULTIPART) + ->label('sdk.response.model', Response::MODEL_MULTIPART) + ->label('sdk.mock', true) + ->param('body', '', new File(), 'Sample file param', false, [], true) + ->inject('response') + ->inject('request') + ->action(function (string $body, Response $response, Request $request) { + if (empty($body)) { + $file = $request->getFiles('body'); + + if (empty($file)) { + $file = $request->getFiles(0); + } + + if (isset($file['tmp_name'])) { + $body = \file_get_contents($file['tmp_name']); + } else { + $body = ''; + } + } + + $response->multipart([ + 'responseBody' => $body + ]); + }); + App::get('/v1/mock/tests/general/redirect') ->desc('Redirect') ->groups(['mock']) @@ -421,7 +490,7 @@ ->label('sdk.response.model', Response::MODEL_MOCK) ->label('sdk.mock', true) ->inject('response') - ->action(function (UtopiaSwooleResponse $response) { + ->action(function (Response $response) { $response->redirect('/v1/mock/tests/general/redirect/done'); }); @@ -454,7 +523,7 @@ ->label('sdk.mock', true) ->inject('response') ->inject('request') - ->action(function (UtopiaSwooleResponse $response, Request $request) { + ->action(function (Response $response, Request $request) { $response->addCookie('cookieName', 'cookieValue', \time() + 31536000, '/', $request->getHostname(), true, true); }); @@ -489,7 +558,7 @@ ->label('sdk.response.model', Response::MODEL_NONE) ->label('sdk.mock', true) ->inject('response') - ->action(function (UtopiaSwooleResponse $response) { + ->action(function (Response $response) { $response->noContent(); }); @@ -566,7 +635,7 @@ ->label('sdk.response.model', Response::MODEL_ANY) ->label('sdk.mock', true) ->inject('response') - ->action(function (UtopiaSwooleResponse $response) { + ->action(function (Response $response) { $response ->setStatusCode(502) @@ -588,7 +657,7 @@ ->param('success', '', new Text(1024), 'OAuth2 success redirect URI.') ->param('failure', '', new Text(1024), 'OAuth2 failure redirect URI.') ->inject('response') - ->action(function (string $clientId, array $scopes, string $state, string $success, string $failure, UtopiaSwooleResponse $response) { + ->action(function (string $clientId, array $scopes, string $state, string $success, string $failure, Response $response) { $response->redirect($success . '?' . \http_build_query(['code' => 'abcdef', 'state' => $state])); }); @@ -606,7 +675,7 @@ ->param('code', '', new Text(100), 'OAuth2 state.', true) ->param('refresh_token', '', new Text(100), 'OAuth2 refresh token.', true) ->inject('response') - ->action(function (string $client_id, string $client_secret, string $grantType, string $redirectURI, string $code, string $refreshToken, UtopiaSwooleResponse $response) { + ->action(function (string $client_id, string $client_secret, string $grantType, string $redirectURI, string $code, string $refreshToken, Response $response) { if ($client_id != '1') { throw new Exception(Exception::GENERAL_MOCK, 'Invalid client ID'); } @@ -645,7 +714,7 @@ ->label('docs', false) ->param('token', '', new Text(100), 'OAuth2 Access Token.') ->inject('response') - ->action(function (string $token, UtopiaSwooleResponse $response) { + ->action(function (string $token, Response $response) { if ($token != '123456') { throw new Exception(Exception::GENERAL_MOCK, 'Invalid token'); } @@ -663,7 +732,7 @@ ->label('scope', 'public') ->label('docs', false) ->inject('response') - ->action(function (UtopiaSwooleResponse $response) { + ->action(function (Response $response) { $response->json([ 'result' => 'success', @@ -676,7 +745,7 @@ ->label('scope', 'public') ->label('docs', false) ->inject('response') - ->action(function (UtopiaSwooleResponse $response) { + ->action(function (Response $response) { $response ->setStatusCode(Response::STATUS_CODE_BAD_REQUEST) @@ -690,7 +759,7 @@ ->inject('utopia') ->inject('response') ->inject('request') - ->action(function (App $utopia, UtopiaSwooleResponse $response, Request $request) { + ->action(function (App $utopia, Response $response, Request $request) { $result = []; $route = $utopia->getRoute(); @@ -709,7 +778,9 @@ throw new Exception(Exception::GENERAL_MOCK, 'Failed to save results', 500); } - $response->json(['result' => $route->getMethod() . ':' . $route->getPath() . ':passed']); + if ($route->getPath() !== '/v1/mock/tests/general/multipart') { + $response->json(['result' => $route->getMethod() . ':' . $route->getPath() . ':passed']); + } }); App::error() @@ -798,7 +869,7 @@ function () use ($http) { $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { $request = new Request($swooleRequest); - $response = new UtopiaSwooleResponse($swooleResponse); + $response = new Response($swooleResponse); $app = new App('UTC'); diff --git a/mock-server/composer.json b/mock-server/composer.json index 66ee3931c..9b01faaeb 100644 --- a/mock-server/composer.json +++ b/mock-server/composer.json @@ -10,7 +10,8 @@ "utopia-php/framework": "0.33.*", "utopia-php/database": "0.48.*", "utopia-php/cli": "0.16.*", - "utopia-php/swoole": "0.8.*" + "utopia-php/swoole": "0.8.*", + "utopia-php/fetch": "0.2.*" }, "require-dev": { "swoole/ide-helper": "5.1.2" diff --git a/mock-server/composer.lock b/mock-server/composer.lock index 9acf97cf3..2e308d819 100644 --- a/mock-server/composer.lock +++ b/mock-server/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e8e3df78a113bec48bb61da0227ea50f", + "content-hash": "b2b8c7f2f6927706fbb3f8a65a0e3752", "packages": [ { "name": "jean85/pretty-package-versions", - "version": "2.0.5", + "version": "2.0.6", "source": { "type": "git", "url": "https://github.com/Jean85/pretty-package-versions.git", - "reference": "ae547e455a3d8babd07b96966b17d7fd21d9c6af" + "reference": "f9fdd29ad8e6d024f52678b570e5593759b550b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/ae547e455a3d8babd07b96966b17d7fd21d9c6af", - "reference": "ae547e455a3d8babd07b96966b17d7fd21d9c6af", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/f9fdd29ad8e6d024f52678b570e5593759b550b4", + "reference": "f9fdd29ad8e6d024f52678b570e5593759b550b4", "shasum": "" }, "require": { @@ -25,9 +25,9 @@ "php": "^7.1|^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.17", + "friendsofphp/php-cs-fixer": "^3.2", "jean85/composer-provided-replaced-stub-package": "^1.0", - "phpstan/phpstan": "^0.12.66", + "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^7.5|^8.5|^9.4", "vimeo/psalm": "^4.3" }, @@ -61,9 +61,9 @@ ], "support": { "issues": "https://github.com/Jean85/pretty-package-versions/issues", - "source": "https://github.com/Jean85/pretty-package-versions/tree/2.0.5" + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.0.6" }, - "time": "2021-10-08T21:21:46+00:00" + "time": "2024-03-08T09:58:59+00:00" }, { "name": "mongodb/mongodb", @@ -136,16 +136,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b" + "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", - "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", + "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", "shasum": "" }, "require": { @@ -196,7 +196,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" }, "funding": [ { @@ -212,20 +212,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "utopia-php/cache", - "version": "0.9.0", + "version": "0.9.1", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "4fc7b4789b5f0ce74835c1ecfec4f3afe6f0e34e" + "reference": "552b4c554bb14d0c529631ce304cdf4a2b9d06a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/4fc7b4789b5f0ce74835c1ecfec4f3afe6f0e34e", - "reference": "4fc7b4789b5f0ce74835c1ecfec4f3afe6f0e34e", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/552b4c554bb14d0c529631ce304cdf4a2b9d06a6", + "reference": "552b4c554bb14d0c529631ce304cdf4a2b9d06a6", "shasum": "" }, "require": { @@ -260,9 +260,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/0.9.0" + "source": "https://github.com/utopia-php/cache/tree/0.9.1" }, - "time": "2024-01-07T18:11:23+00:00" + "time": "2024-03-19T17:07:20+00:00" }, { "name": "utopia-php/cli", @@ -315,16 +315,16 @@ }, { "name": "utopia-php/database", - "version": "0.48.2", + "version": "0.48.4", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "0a231a2874fdbc0cf2ae2170b3f132fdee0ddfd4" + "reference": "02f20bd901b8fab26d7dc2c58f7da1d6a08d21c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/0a231a2874fdbc0cf2ae2170b3f132fdee0ddfd4", - "reference": "0a231a2874fdbc0cf2ae2170b3f132fdee0ddfd4", + "url": "https://api.github.com/repos/utopia-php/database/zipball/02f20bd901b8fab26d7dc2c58f7da1d6a08d21c0", + "reference": "02f20bd901b8fab26d7dc2c58f7da1d6a08d21c0", "shasum": "" }, "require": { @@ -332,7 +332,7 @@ "ext-pdo": "*", "php": ">=8.0", "utopia-php/cache": "0.9.*", - "utopia-php/framework": "0.*.*", + "utopia-php/framework": "0.33.*", "utopia-php/mongo": "0.3.*" }, "require-dev": { @@ -365,22 +365,61 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.48.2" + "source": "https://github.com/utopia-php/database/tree/0.48.4" }, - "time": "2024-02-02T14:10:14+00:00" + "time": "2024-02-23T03:22:55+00:00" + }, + { + "name": "utopia-php/fetch", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/fetch.git", + "reference": "1423c0ee3eef944d816ca6e31706895b585aea82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/fetch/zipball/1423c0ee3eef944d816ca6e31706895b585aea82", + "reference": "1423c0ee3eef944d816ca6e31706895b585aea82", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "^1.5.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Fetch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library that provides an interface for making HTTP Requests.", + "support": { + "issues": "https://github.com/utopia-php/fetch/issues", + "source": "https://github.com/utopia-php/fetch/tree/0.2.1" + }, + "time": "2024-03-18T11:50:59+00:00" }, { "name": "utopia-php/framework", - "version": "0.33.2", + "version": "0.33.8", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "b1423ca3e3b61c6c4c2e619d2cb80672809a19f3" + "reference": "a7f577540a25cb90896fef2b64767bf8d700f3c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/b1423ca3e3b61c6c4c2e619d2cb80672809a19f3", - "reference": "b1423ca3e3b61c6c4c2e619d2cb80672809a19f3", + "url": "https://api.github.com/repos/utopia-php/http/zipball/a7f577540a25cb90896fef2b64767bf8d700f3c5", + "reference": "a7f577540a25cb90896fef2b64767bf8d700f3c5", "shasum": "" }, "require": { @@ -410,9 +449,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.2" + "source": "https://github.com/utopia-php/http/tree/0.33.8" }, - "time": "2024-01-31T10:35:59+00:00" + "time": "2024-08-15T14:10:09+00:00" }, { "name": "utopia-php/mongo", diff --git a/mock-server/docker-compose.yml b/mock-server/docker-compose.yml index 842bcf8e3..7edc69ad5 100644 --- a/mock-server/docker-compose.yml +++ b/mock-server/docker-compose.yml @@ -1,6 +1,8 @@ services: mockapi: container_name: mockapi + ports: + - 3175:80 build: context: . args: @@ -23,4 +25,4 @@ networks: name: mockapi volumes: - mockapi-cache: \ No newline at end of file + mockapi-cache: diff --git a/mock-server/resources/file.png b/mock-server/resources/file.png new file mode 100644 index 000000000..688533b76 Binary files /dev/null and b/mock-server/resources/file.png differ diff --git a/mock-server/src/Utopia/BodyMultipart.php b/mock-server/src/Utopia/BodyMultipart.php new file mode 100644 index 000000000..0acaa1378 --- /dev/null +++ b/mock-server/src/Utopia/BodyMultipart.php @@ -0,0 +1,151 @@ + $parts + */ + private array $parts = []; + private string $boundary = ""; + + public function __construct(string $boundary = null) + { + if (is_null($boundary)) { + $this->boundary = self::generateBoundary(); + } else { + $this->boundary = $boundary; + } + } + + public static function generateBoundary(): string + { + return '-----------------------------' . \uniqid(); + } + + public function load(string $body): self + { + $eol = "\r\n"; + + $sections = \explode('--' . $this->boundary, $body); + + foreach ($sections as $section) { + if (empty($section)) { + continue; + } + + if (strpos($section, $eol) === 0) { + $section = substr($section, \strlen($eol)); + } + + if (substr($section, -2) === $eol) { + $section = substr($section, 0, -1 * \strlen($eol)); + } + + if ($section == '--') { + continue; + } + + $partChunks = \explode($eol . $eol, $section, 2); + + if (\count($partChunks) < 2) { + continue; // Broken part + } + + [ $partHeaders, $partBody ] = $partChunks; + $partHeaders = \explode($eol, $partHeaders); + + $partName = ""; + foreach ($partHeaders as $partHeader) { + if (!empty($partName)) { + break; + } + + $partHeaderArray = \explode(':', $partHeader, 2); + + $partHeaderName = \strtolower($partHeaderArray[0] ?? ''); + $partHeaderValue = $partHeaderArray[1] ?? ''; + if ($partHeaderName == "content-disposition") { + $dispositionChunks = \explode("; ", $partHeaderValue); + foreach ($dispositionChunks as $dispositionChunk) { + $dispositionChunkValues = \explode("=", $dispositionChunk, 2); + if (\count($dispositionChunkValues) >= 2) { + if ($dispositionChunkValues[0] === "name") { + $partName = \trim($dispositionChunkValues[1], "\""); + break; + } + } + } + } + } + + if (!empty($partName)) { + $this->parts[$partName] = $partBody; + } + } + return $this; + } + + /** + * @return array + */ + public function getParts(): array + { + return $this->parts ?? []; + } + + public function getPart(string $key, mixed $default = ''): mixed + { + return $this->parts[$key] ?? $default; + } + + public function setPart(string $key, mixed $value): self + { + $this->parts[$key] = $value; + return $this; + } + + public function getBoundary(): string + { + return $this->boundary; + } + + public function setBoundary(string $boundary): self + { + $this->boundary = $boundary; + return $this; + } + + public function exportHeader(): string + { + return 'multipart/form-data; boundary=' . $this->boundary; + } + + public function exportBody(): string + { + $eol = "\r\n"; + $query = '--' . $this->boundary; + + foreach ($this->parts as $key => $value) { + $query .= $eol . 'Content-Disposition: form-data; name="' . $key . '"'; + + if (\is_array($value)) { + $query .= $eol . 'Content-Type: application/json'; + $value = \json_encode($value); + } + + $query .= $eol . $eol; + if ($value === false) { + $query .= 0 . $eol; + } else { + $query .= $value . $eol; + } + $query .= '--' . $this->boundary; + } + + $query .= "--" . $eol; + + return $query; + } +} diff --git a/mock-server/src/Utopia/Response.php b/mock-server/src/Utopia/Response.php index 756049364..732257fea 100644 --- a/mock-server/src/Utopia/Response.php +++ b/mock-server/src/Utopia/Response.php @@ -2,14 +2,17 @@ namespace Utopia\MockServer\Utopia; -use Utopia\Swoole\Response as SwooleResponse; +use Utopia\MockServer\Utopia\BodyMultipart; +use Swoole\Http\Response as SwooleResponse; +use Utopia\CLI\Console; use Utopia\Database\Document; +use Utopia\Swoole\Response as UtopiaResponse; /** * @method int getStatusCode() * @method Response setStatusCode(int $code = 200) */ -class Response extends SwooleResponse +class Response extends UtopiaResponse { // General public const MODEL_NONE = 'none'; @@ -21,6 +24,7 @@ class Response extends SwooleResponse public const MODEL_METRIC_LIST = 'metricList'; public const MODEL_ERROR_DEV = 'errorDev'; public const MODEL_BASE_LIST = 'baseList'; + public const MODEL_MULTIPART = 'multipart'; // Mock public const MODEL_MOCK = 'mock'; @@ -39,6 +43,7 @@ class Response extends SwooleResponse */ public function __construct(SwooleResponse $response) { + parent::__construct($response); } /** @@ -46,6 +51,7 @@ public function __construct(SwooleResponse $response) */ public const CONTENT_TYPE_YAML = 'application/x-yaml'; public const CONTENT_TYPE_NULL = 'null'; + public const CONTENT_TYPE_MULTIPART = 'multipart/form-data'; /** * List of defined output objects @@ -91,6 +97,18 @@ public function getModels(): array return $this->models; } + public function multipart(array $data): void + { + $multipart = new BodyMultipart(); + foreach ($data as $key => $value) { + $multipart->setPart($key, $value); + } + + $this + ->setContentType($multipart->exportHeader()) + ->send($multipart->exportBody()); + } + /** * Validate response objects and outputs * the response according to given format type @@ -118,6 +136,10 @@ public function dynamic(Document $document, string $model): void case self::CONTENT_TYPE_NULL: break; + case self::CONTENT_TYPE_MULTIPART: + $this->multipart(!empty($output) ? $output : new \stdClass()); + break; + default: if ($model === self::MODEL_NONE) { $this->noContent(); @@ -128,6 +150,8 @@ public function dynamic(Document $document, string $model): void } } + + /** * Generate valid response object from document data * diff --git a/src/SDK/Language.php b/src/SDK/Language.php index ac4243ef9..3322c0ec4 100644 --- a/src/SDK/Language.php +++ b/src/SDK/Language.php @@ -11,6 +11,7 @@ abstract class Language public const TYPE_ARRAY = 'array'; public const TYPE_OBJECT = 'object'; public const TYPE_FILE = 'file'; + public const TYPE_PAYLOAD = 'payload'; /** * @var array @@ -93,12 +94,7 @@ public function getFunctions(): array return []; } - protected function toPascalCase(string $value): string - { - return \ucfirst($this->toCamelCase($value)); - } - - protected function toCamelCase($str): string + protected function toPascalCase($str): string { // Normalize the string to decompose accented characters $str = \Normalizer::normalize($str, \Normalizer::FORM_D); @@ -106,14 +102,23 @@ protected function toCamelCase($str): string // Remove accents and other residual non-ASCII characters $str = \preg_replace('/\p{M}/u', '', $str); + // Insert spaces before uppercase letters where appropriate + $str = \preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $str); + $str = \preg_replace('/(?<=[A-Z])(?=[A-Z][a-z])/', ' ', $str); + + // Replace any sequence of non-alphanumeric characters with a space $str = \preg_replace('/[^a-zA-Z0-9]+/', ' ', $str); $str = \trim($str); $str = strtolower($str); + + // Uppercase the first letter of each word, then remove spaces $str = \ucwords($str); - $str = \str_replace(' ', '', $str); - $str = \lcfirst($str); + return \str_replace(' ', '', $str); + } - return $str; + protected function toCamelCase($str): string + { + return \lcfirst($this->toPascalCase($str)); } protected function toSnakeCase($str): string diff --git a/src/SDK/Language/Android.php b/src/SDK/Language/Android.php index 8f1fdb1f9..0fcf26bab 100644 --- a/src/SDK/Language/Android.php +++ b/src/SDK/Language/Android.php @@ -147,8 +147,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/models/InputFile.kt', - 'template' => '/android/library/src/main/java/io/package/models/InputFile.kt.twig', + 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/models/Payload.kt', + 'template' => '/android/library/src/main/java/io/package/models/Payload.kt.twig', ], [ 'scope' => 'default', diff --git a/src/SDK/Language/Dart.php b/src/SDK/Language/Dart.php index eb0052fb6..bda1727aa 100644 --- a/src/SDK/Language/Dart.php +++ b/src/SDK/Language/Dart.php @@ -139,7 +139,8 @@ public function getTypeName(array $parameter, array $spec = []): string case self::TYPE_STRING: return 'String'; case self::TYPE_FILE: - return 'InputFile'; + case self::TYPE_PAYLOAD: + return 'Payload'; case self::TYPE_BOOLEAN: return 'bool'; case self::TYPE_ARRAY: @@ -207,6 +208,10 @@ public function getParamDefault(array $param): string case self::TYPE_STRING: $output .= "'{$default}'"; break; + case self::TYPE_FILE: + case self::TYPE_PAYLOAD: + $output .= 'Payload'; + break; } } @@ -227,7 +232,10 @@ public function getParamExample(array $param): string if (empty($example) && $example !== 0 && $example !== false) { switch ($type) { case self::TYPE_FILE: - $output .= 'InputFile(path: \'./path-to-files/image.jpg\', filename: \'image.jpg\')'; + $output .= "Payload.fromFile(path: '/path/to/file.png')"; + break; + case self::TYPE_PAYLOAD: + $output .= "Payload.fromJson({ 'x': 'y' })"; break; case self::TYPE_NUMBER: case self::TYPE_INTEGER: @@ -465,8 +473,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => '/test/src/input_file_test.dart', - 'template' => 'dart/test/src/input_file_test.dart.twig', + 'destination' => '/test/src/payload_test.dart', + 'template' => 'dart/test/src/payload_test.dart.twig', ], [ 'scope' => 'default', @@ -485,8 +493,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => 'lib/src/input_file.dart', - 'template' => 'dart/lib/src/input_file.dart.twig', + 'destination' => 'lib/payload.dart', + 'template' => 'dart/lib/payload.dart.twig', ], [ 'scope' => 'enum', @@ -507,6 +515,9 @@ public function getFilters(): array return implode("\n", $value); }, ['is_safe' => ['html']]), new TwigFilter('caseEnumKey', function (string $value) { + if (ctype_upper($value)) { + return \strtolower($value); + } return $this->toCamelCase($value); }), ]; diff --git a/src/SDK/Language/Deno.php b/src/SDK/Language/Deno.php index 787cd7a54..b59e7a2f8 100644 --- a/src/SDK/Language/Deno.php +++ b/src/SDK/Language/Deno.php @@ -2,8 +2,6 @@ namespace Appwrite\SDK\Language; -use Twig\TwigFilter; - class Deno extends JS { /** @@ -72,8 +70,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => 'src/inputFile.ts', - 'template' => 'deno/src/inputFile.ts.twig', + 'destination' => 'src/payload.ts', + 'template' => 'deno/src/payload.ts.twig', ], [ 'scope' => 'default', @@ -85,6 +83,11 @@ public function getFiles(): array 'destination' => '/src/models.d.ts', 'template' => 'deno/src/models.d.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => '/src/multipart.ts', + 'template' => 'deno/src/multipart.ts.twig', + ], [ 'scope' => 'default', 'destination' => '/src/exception.ts', @@ -143,7 +146,8 @@ public function getTypeName(array $parameter, array $spec = []): string return match ($parameter['type']) { self::TYPE_INTEGER => 'number', self::TYPE_STRING => 'string', - self::TYPE_FILE => 'InputFile', + self::TYPE_FILE, + self::TYPE_PAYLOAD => 'Payload', self::TYPE_BOOLEAN => 'boolean', self::TYPE_ARRAY => (!empty(($parameter['array'] ?? [])['type']) && !\is_array($parameter['array']['type'])) ? $this->getTypeName($parameter['array']) . '[]' @@ -180,8 +184,11 @@ public function getParamExample(array $param): string case self::TYPE_OBJECT: $output .= '{}'; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson({ x: "y" })'; + break; case self::TYPE_FILE: - $output .= "InputFile.fromPath('/path/to/file.png', 'file.png')"; + $output .= "Payload.fromFile('/path/to/file.png')"; break; } } else { @@ -198,8 +205,11 @@ public function getParamExample(array $param): string case self::TYPE_STRING: $output .= "'{$example}'"; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson({ x: "y" })'; + break; case self::TYPE_FILE: - $output .= "InputFile.fromPath('/path/to/file.png', 'file.png')"; + $output .= "Payload.fromFile('/path/to/file.png')"; break; } } diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index a76ef40dd..b5016f9e7 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -171,7 +171,8 @@ public function getTypeName(array $parameter, array $spec = []): string self::TYPE_NUMBER => 'double', self::TYPE_STRING => 'string', self::TYPE_BOOLEAN => 'bool', - self::TYPE_FILE => 'InputFile', + self::TYPE_FILE => 'Payload', + self::TYPE_PAYLOAD => 'Payload', self::TYPE_ARRAY => (!empty(($parameter['array'] ?? [])['type']) && !\is_array($parameter['array']['type'])) ? 'List<' . $this->getTypeName($parameter['array']) . '>' : 'List', @@ -242,8 +243,11 @@ public function getParamExample(array $param): string if (empty($example) && $example !== 0 && $example !== false) { switch ($type) { + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson(new KeyValuePair("x", "y"))'; + break; case self::TYPE_FILE: - $output .= 'InputFile.FromPath("./path-to-files/image.jpg")'; + $output .= 'Payload.FromFile("/path/to/file.png")'; break; case self::TYPE_NUMBER: case self::TYPE_INTEGER: @@ -286,8 +290,14 @@ public function getParamExample(array $param): string case self::TYPE_BOOLEAN: $output .= ($example) ? 'true' : 'false'; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson(new KeyValuePair("x", "y"))'; + break; + case self::TYPE_FILE: + $output .= 'Payload.FromFile("/path/to/file.png")'; + break; case self::TYPE_STRING: - $output .= "\"{$example}\""; + $output .= '"{$example}"'; break; } } @@ -393,8 +403,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => '{{ spec.title | caseUcfirst }}/Models/InputFile.cs', - 'template' => 'dotnet/Package/Models/InputFile.cs.twig', + 'destination' => '{{ spec.title | caseUcfirst }}/Models/Payload.cs', + 'template' => 'dotnet/Package/Models/Payload.cs.twig', ], [ 'scope' => 'default', diff --git a/src/SDK/Language/Flutter.php b/src/SDK/Language/Flutter.php index 6c6fd8f17..2be4060a5 100644 --- a/src/SDK/Language/Flutter.php +++ b/src/SDK/Language/Flutter.php @@ -92,8 +92,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => 'lib/src/input_file.dart', - 'template' => 'dart/lib/src/input_file.dart.twig', + 'destination' => 'lib/payload.dart', + 'template' => 'dart/lib/payload.dart.twig', ], [ 'scope' => 'default', @@ -143,12 +143,12 @@ public function getFiles(): array [ 'scope' => 'default', 'destination' => '/lib/src/client_mixin.dart', - 'template' => 'flutter/lib/src/client_mixin.dart.twig', + 'template' => 'dart/lib/src/client_mixin.dart.twig', ], [ 'scope' => 'default', 'destination' => '/lib/src/client_stub.dart', - 'template' => 'flutter/lib/src/client_stub.dart.twig', + 'template' => 'dart/lib/src/client_stub.dart.twig', ], [ 'scope' => 'default', @@ -218,12 +218,12 @@ public function getFiles(): array [ 'scope' => 'default', 'destination' => '/lib/client_io.dart', - 'template' => 'flutter/lib/client_io.dart.twig', + 'template' => 'dart/lib/client_io.dart.twig', ], [ 'scope' => 'default', 'destination' => '/lib/client_browser.dart', - 'template' => 'flutter/lib/client_browser.dart.twig', + 'template' => 'dart/lib/client_browser.dart.twig', ], [ 'scope' => 'default', @@ -312,8 +312,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => '/test/src/input_file_test.dart', - 'template' => 'dart/test/src/input_file_test.dart.twig', + 'destination' => '/test/src/payload_test.dart', + 'template' => 'dart/test/src/payload_test.dart.twig', ], [ 'scope' => 'default', diff --git a/src/SDK/Language/Go.php b/src/SDK/Language/Go.php index 76c70c96a..fc6df3e3e 100644 --- a/src/SDK/Language/Go.php +++ b/src/SDK/Language/Go.php @@ -90,8 +90,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => 'file/inputFile.go', - 'template' => 'go/inputFile.go.twig', + 'destination' => 'payload/payload.go', + 'template' => 'go/payload.go.twig', ], [ 'scope' => 'default', @@ -141,10 +141,12 @@ public function getTypeName(array $parameter, array $spec = []): string if (str_contains($parameter['description'] ?? '', 'Collection attributes') || str_contains($parameter['description'] ?? '', 'List of attributes')) { return '[]map[string]any'; } + return match ($parameter['type']) { self::TYPE_INTEGER => 'int', self::TYPE_NUMBER => 'float64', - self::TYPE_FILE => 'file.InputFile', + self::TYPE_PAYLOAD, + self::TYPE_FILE => '*payload.Payload', self::TYPE_STRING => 'string', self::TYPE_BOOLEAN => 'bool', self::TYPE_OBJECT => 'interface{}', @@ -241,8 +243,11 @@ public function getParamExample(array $param): string case self::TYPE_ARRAY: $output .= '[]interface{}{}'; break; + case self::TYPE_PAYLOAD: + $output .= 'payload.NewPayloadFromJson(map[string]interface{}{ "x": "y" })'; + break; case self::TYPE_FILE: - $output .= 'file.NewInputFile("/path/to/file.png", "file.png")'; + $output .= 'payload.NewPayloadFromFile("/path/to/file.png")'; break; } } else { @@ -267,10 +272,13 @@ public function getParamExample(array $param): string $output .= ($example) ? 'true' : 'false'; break; case self::TYPE_STRING: - $output .= "\"{$example}\""; + $output .= '"{$example}"'; + break; + case self::TYPE_PAYLOAD: + $output .= 'payload.NewPayloadFromJson(map[string]interface{}{ "x": "y" })'; break; case self::TYPE_FILE: - $output .= 'file.NewInputFile("/path/to/file.png", "file.png")'; + $output .= 'payload.NewPayloadFromFile("/path/to/file.png")'; break; } } diff --git a/src/SDK/Language/Kotlin.php b/src/SDK/Language/Kotlin.php index 53c4a24a2..be279cdc0 100644 --- a/src/SDK/Language/Kotlin.php +++ b/src/SDK/Language/Kotlin.php @@ -116,7 +116,8 @@ public function getTypeName(array $parameter, array $spec = []): string self::TYPE_INTEGER => 'Long', self::TYPE_NUMBER => 'Double', self::TYPE_STRING => 'String', - self::TYPE_FILE => 'InputFile', + self::TYPE_PAYLOAD, + self::TYPE_FILE => 'Payload', self::TYPE_BOOLEAN => 'Boolean', self::TYPE_ARRAY => (!empty(($parameter['array'] ?? [])['type']) && !\is_array($parameter['array']['type'])) ? 'List<' . $this->getTypeName($parameter['array']) . '>' @@ -132,9 +133,9 @@ public function getTypeName(array $parameter, array $spec = []): string */ public function getParamDefault(array $param): string { - $type = $param['type'] ?? ''; - $default = $param['default'] ?? ''; - $required = $param['required'] ?? ''; + $type = $param['type'] ?? ''; + $default = $param['default'] ?? ''; + $required = $param['required'] ?? ''; if ($required) { return ''; @@ -191,15 +192,18 @@ public function getParamDefault(array $param): string */ public function getParamExample(array $param): string { - $type = $param['type'] ?? ''; - $example = $param['example'] ?? ''; + $type = $param['type'] ?? ''; + $example = $param['example'] ?? ''; $output = ''; if (empty($example) && $example !== 0 && $example !== false) { switch ($type) { + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson(mapOf("x" to "y" as Any))'; + break; case self::TYPE_FILE: - $output .= 'InputFile.fromPath("file.png")'; + $output .= 'Payload.fromFile("/path/to/file.png")'; break; case self::TYPE_NUMBER: case self::TYPE_INTEGER: @@ -240,8 +244,14 @@ public function getParamExample(array $param): string case self::TYPE_BOOLEAN: $output .= ($example) ? 'true' : 'false'; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson(mapOf("x" to "y" as Any))'; + break; + case self::TYPE_FILE: + $output .= 'Payload.fromFile("/path/to/file.png")'; + break; case self::TYPE_STRING: - $output .= "\"{$example}\""; + $output .= '"{$example}"'; break; } } @@ -257,165 +267,165 @@ public function getFiles(): array return [ // Config for root project [ - 'scope' => 'copy', - 'destination' => '.github/workflows/publish.yml', - 'template' => '/kotlin/.github/workflows/publish.yml', + 'scope' => 'copy', + 'destination' => '.github/workflows/publish.yml', + 'template' => '/kotlin/.github/workflows/publish.yml', ], [ - 'scope' => 'method', - 'destination' => 'docs/examples/kotlin/{{service.name | caseLower}}/{{method.name | caseDash}}.md', - 'template' => '/kotlin/docs/kotlin/example.md.twig', + 'scope' => 'method', + 'destination' => 'docs/examples/kotlin/{{service.name | caseLower}}/{{method.name | caseDash}}.md', + 'template' => '/kotlin/docs/kotlin/example.md.twig', ], [ - 'scope' => 'method', - 'destination' => 'docs/examples/java/{{service.name | caseLower}}/{{method.name | caseDash}}.md', - 'template' => '/kotlin/docs/java/example.md.twig', + 'scope' => 'method', + 'destination' => 'docs/examples/java/{{service.name | caseLower}}/{{method.name | caseDash}}.md', + 'template' => '/kotlin/docs/java/example.md.twig', ], [ - 'scope' => 'copy', - 'destination' => 'gradle/wrapper/gradle-wrapper.jar', - 'template' => 'kotlin/gradle/wrapper/gradle-wrapper.jar', + 'scope' => 'copy', + 'destination' => 'gradle/wrapper/gradle-wrapper.jar', + 'template' => 'kotlin/gradle/wrapper/gradle-wrapper.jar', ], [ - 'scope' => 'copy', - 'destination' => 'gradle/wrapper/gradle-wrapper.properties', - 'template' => '/kotlin/gradle/wrapper/gradle-wrapper.properties', + 'scope' => 'copy', + 'destination' => 'gradle/wrapper/gradle-wrapper.properties', + 'template' => '/kotlin/gradle/wrapper/gradle-wrapper.properties', ], [ - 'scope' => 'copy', - 'destination' => 'scripts/configure.gradle', - 'template' => '/kotlin/scripts/configure.gradle', + 'scope' => 'copy', + 'destination' => 'scripts/configure.gradle', + 'template' => '/kotlin/scripts/configure.gradle', ], [ - 'scope' => 'copy', - 'destination' => 'scripts/publish.gradle', - 'template' => '/kotlin/scripts/publish.gradle', + 'scope' => 'copy', + 'destination' => 'scripts/publish.gradle', + 'template' => '/kotlin/scripts/publish.gradle', ], [ - 'scope' => 'copy', - 'destination' => 'scripts/setup.gradle', - 'template' => '/kotlin/scripts/setup.gradle', + 'scope' => 'copy', + 'destination' => 'scripts/setup.gradle', + 'template' => '/kotlin/scripts/setup.gradle', ], [ - 'scope' => 'copy', - 'destination' => '.gitignore', - 'template' => '/kotlin/.gitignore', + 'scope' => 'copy', + 'destination' => '.gitignore', + 'template' => '/kotlin/.gitignore', ], [ - 'scope' => 'default', - 'destination' => 'build.gradle', - 'template' => '/kotlin/build.gradle.twig', + 'scope' => 'default', + 'destination' => 'build.gradle', + 'template' => '/kotlin/build.gradle.twig', ], [ - 'scope' => 'default', - 'destination' => 'CHANGELOG.md', - 'template' => '/kotlin/CHANGELOG.md.twig', + 'scope' => 'default', + 'destination' => 'CHANGELOG.md', + 'template' => '/kotlin/CHANGELOG.md.twig', ], [ - 'scope' => 'copy', - 'destination' => 'gradle.properties', - 'template' => '/kotlin/gradle.properties', + 'scope' => 'copy', + 'destination' => 'gradle.properties', + 'template' => '/kotlin/gradle.properties', ], [ - 'scope' => 'copy', - 'destination' => 'gradlew', - 'template' => '/kotlin/gradlew', + 'scope' => 'copy', + 'destination' => 'gradlew', + 'template' => '/kotlin/gradlew', ], [ - 'scope' => 'copy', - 'destination' => 'gradlew.bat', - 'template' => '/kotlin/gradlew.bat', + 'scope' => 'copy', + 'destination' => 'gradlew.bat', + 'template' => '/kotlin/gradlew.bat', ], [ - 'scope' => 'default', - 'destination' => 'LICENSE.md', - 'template' => '/kotlin/LICENSE.md.twig', + 'scope' => 'default', + 'destination' => 'LICENSE.md', + 'template' => '/kotlin/LICENSE.md.twig', ], [ - 'scope' => 'default', - 'destination' => 'README.md', - 'template' => '/kotlin/README.md.twig', + 'scope' => 'default', + 'destination' => 'README.md', + 'template' => '/kotlin/README.md.twig', ], [ - 'scope' => 'default', - 'destination' => 'settings.gradle', - 'template' => '/kotlin/settings.gradle.twig', + 'scope' => 'default', + 'destination' => 'settings.gradle', + 'template' => '/kotlin/settings.gradle.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Client.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Client.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Permission.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/Permission.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Permission.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/Permission.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Role.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/Role.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Role.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/Role.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/ID.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/ID.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/ID.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/ID.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Query.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/Query.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Query.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/Query.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/coroutines/Callback.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/coroutines/Callback.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/coroutines/Callback.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/coroutines/Callback.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/exceptions/{{spec.title | caseUcfirst}}Exception.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/exceptions/Exception.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/exceptions/{{spec.title | caseUcfirst}}Exception.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/exceptions/Exception.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/extensions/JsonExtensions.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/extensions/JsonExtensions.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/extensions/JsonExtensions.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/extensions/JsonExtensions.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/extensions/TypeExtensions.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/extensions/TypeExtensions.kt.twig', - 'minify' => false, + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/extensions/TypeExtensions.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/extensions/TypeExtensions.kt.twig', + 'minify' => false, ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/services/Service.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/services/Service.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/services/Service.kt.twig', ], [ - 'scope' => 'service', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/services/{{service.name | caseUcfirst}}.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig', + 'scope' => 'service', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/services/{{service.name | caseUcfirst}}.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/services/ServiceTemplate.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/models/InputFile.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/models/InputFile.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/models/Payload.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/models/Payload.kt.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/models/UploadProgress.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/models/UploadProgress.kt.twig', + 'scope' => 'default', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/models/UploadProgress.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/models/UploadProgress.kt.twig', ], [ - 'scope' => 'definition', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/models/{{ definition.name | caseUcfirst }}.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig', + 'scope' => 'definition', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/models/{{ definition.name | caseUcfirst }}.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig', ], [ - 'scope' => 'enum', - 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/enums/{{ enum.name | caseUcfirst }}.kt', - 'template' => '/kotlin/src/main/kotlin/io/appwrite/enums/Enum.kt.twig', + 'scope' => 'enum', + 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/enums/{{ enum.name | caseUcfirst }}.kt', + 'template' => '/kotlin/src/main/kotlin/io/appwrite/enums/Enum.kt.twig', ], ]; } diff --git a/src/SDK/Language/Node.php b/src/SDK/Language/Node.php index d6e0a072f..f96799e53 100644 --- a/src/SDK/Language/Node.php +++ b/src/SDK/Language/Node.php @@ -30,7 +30,8 @@ public function getTypeName(array $parameter, array $method = []): string } return 'string[]'; case self::TYPE_FILE: - return "File"; + case self::TYPE_PAYLOAD: + return 'Payload'; case self::TYPE_OBJECT: if (empty($method)) { return $parameter['type']; @@ -79,7 +80,7 @@ public function getReturn(array $method, array $spec): string $this->populateGenerics($method['responseModel'], $spec, $models); $models = array_unique($models); - $models = array_filter($models, fn ($model) => $model != $this->toPascalCase($method['responseModel'])); + $models = array_filter($models, fn($model) => $model != $this->toPascalCase($method['responseModel'])); if (!empty($models)) { $ret .= '<' . implode(', ', $models) . '>'; @@ -92,7 +93,7 @@ public function getReturn(array $method, array $spec): string return 'Promise<{}>'; } - /** + /** * @param array $param * @return string */ @@ -119,8 +120,11 @@ public function getParamExample(array $param): string case self::TYPE_OBJECT: $output .= '{}'; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson({ x: "y" })'; + break; case self::TYPE_FILE: - $output .= "InputFile.fromPath('/path/to/file', 'filename')"; + $output .= "Payload.fromBinary(fs.readFileSync('/path/to/file.png'), 'file.png')"; break; } } else { @@ -137,8 +141,11 @@ public function getParamExample(array $param): string case self::TYPE_STRING: $output .= "'{$example}'"; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson({ x: "y" })'; + break; case self::TYPE_FILE: - $output .= "InputFile.fromPath('/path/to/file', 'filename')"; + $output .= "Payload.fromBinary(fs.readFileSync('/path/to/file.png'), 'file.png')"; break; } } @@ -164,8 +171,8 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => 'src/inputFile.ts', - 'template' => 'node/src/inputFile.ts.twig', + 'destination' => 'src/payload.ts', + 'template' => 'node/src/payload.ts.twig', ], [ 'scope' => 'service', diff --git a/src/SDK/Language/PHP.php b/src/SDK/Language/PHP.php index 0cdae26da..739c38163 100644 --- a/src/SDK/Language/PHP.php +++ b/src/SDK/Language/PHP.php @@ -137,110 +137,110 @@ public function getFiles(): array { return [ [ - 'scope' => 'default', - 'destination' => 'README.md', - 'template' => 'php/README.md.twig', + 'scope' => 'default', + 'destination' => 'README.md', + 'template' => 'php/README.md.twig', //'block' => 'default', ], [ - 'scope' => 'default', - 'destination' => 'CHANGELOG.md', - 'template' => 'php/CHANGELOG.md.twig', + 'scope' => 'default', + 'destination' => 'CHANGELOG.md', + 'template' => 'php/CHANGELOG.md.twig', ], [ - 'scope' => 'default', - 'destination' => 'LICENSE', - 'template' => 'php/LICENSE.twig', + 'scope' => 'default', + 'destination' => 'LICENSE', + 'template' => 'php/LICENSE.twig', ], [ - 'scope' => 'default', - 'destination' => 'composer.json', - 'template' => 'php/composer.json.twig', + 'scope' => 'default', + 'destination' => 'composer.json', + 'template' => 'php/composer.json.twig', ], [ - 'scope' => 'service', - 'destination' => 'docs/{{service.name | caseLower}}.md', - 'template' => 'php/docs/service.md.twig', + 'scope' => 'service', + 'destination' => 'docs/{{service.name | caseLower}}.md', + 'template' => 'php/docs/service.md.twig', ], [ - 'scope' => 'method', - 'destination' => 'docs/examples/{{service.name | caseLower}}/{{method.name | caseDash}}.md', - 'template' => 'php/docs/example.md.twig', + 'scope' => 'method', + 'destination' => 'docs/examples/{{service.name | caseLower}}/{{method.name | caseDash}}.md', + 'template' => 'php/docs/example.md.twig', ], [ - 'scope' => 'default', - 'destination' => 'src/{{ spec.title | caseUcfirst}}/Client.php', - 'template' => 'php/src/Client.php.twig', + 'scope' => 'default', + 'destination' => 'src/{{ spec.title | caseUcfirst}}/Client.php', + 'template' => 'php/src/Client.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'src/{{ spec.title | caseUcfirst}}/Permission.php', - 'template' => 'php/src/Permission.php.twig', + 'scope' => 'default', + 'destination' => 'src/{{ spec.title | caseUcfirst}}/Permission.php', + 'template' => 'php/src/Permission.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'tests/{{ spec.title | caseUcfirst}}/PermissionTest.php', - 'template' => 'php/tests/PermissionTest.php.twig', + 'scope' => 'default', + 'destination' => 'tests/{{ spec.title | caseUcfirst}}/PermissionTest.php', + 'template' => 'php/tests/PermissionTest.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'src/{{ spec.title | caseUcfirst}}/Role.php', - 'template' => 'php/src/Role.php.twig', + 'scope' => 'default', + 'destination' => 'src/{{ spec.title | caseUcfirst}}/Role.php', + 'template' => 'php/src/Role.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'tests/{{ spec.title | caseUcfirst}}/RoleTest.php', - 'template' => 'php/tests/RoleTest.php.twig', + 'scope' => 'default', + 'destination' => 'tests/{{ spec.title | caseUcfirst}}/RoleTest.php', + 'template' => 'php/tests/RoleTest.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'src/{{ spec.title | caseUcfirst}}/ID.php', - 'template' => 'php/src/ID.php.twig', + 'scope' => 'default', + 'destination' => 'src/{{ spec.title | caseUcfirst}}/ID.php', + 'template' => 'php/src/ID.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'tests/{{ spec.title | caseUcfirst}}/IDTest.php', - 'template' => 'php/tests/IDTest.php.twig', + 'scope' => 'default', + 'destination' => 'tests/{{ spec.title | caseUcfirst}}/IDTest.php', + 'template' => 'php/tests/IDTest.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'src/{{ spec.title | caseUcfirst}}/Query.php', - 'template' => 'php/src/Query.php.twig', + 'scope' => 'default', + 'destination' => 'src/{{ spec.title | caseUcfirst}}/Query.php', + 'template' => 'php/src/Query.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'tests/{{ spec.title | caseUcfirst}}/QueryTest.php', - 'template' => 'php/tests/QueryTest.php.twig', + 'scope' => 'default', + 'destination' => 'tests/{{ spec.title | caseUcfirst}}/QueryTest.php', + 'template' => 'php/tests/QueryTest.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'src/{{ spec.title | caseUcfirst}}/InputFile.php', - 'template' => 'php/src/InputFile.php.twig', + 'scope' => 'default', + 'destination' => 'src/{{ spec.title | caseUcfirst}}/Payload.php', + 'template' => 'php/src/Payload.php.twig', ], [ - 'scope' => 'default', - 'destination' => 'src/{{ spec.title | caseUcfirst}}/{{ spec.title | caseUcfirst}}Exception.php', - 'template' => 'php/src/Exception.php.twig', + 'scope' => 'default', + 'destination' => 'src/{{ spec.title | caseUcfirst}}/{{ spec.title | caseUcfirst}}Exception.php', + 'template' => 'php/src/Exception.php.twig', ], [ - 'scope' => 'default', - 'destination' => '/src/{{ spec.title | caseUcfirst}}/Service.php', - 'template' => 'php/src/Service.php.twig', + 'scope' => 'default', + 'destination' => '/src/{{ spec.title | caseUcfirst}}/Service.php', + 'template' => 'php/src/Service.php.twig', ], [ - 'scope' => 'service', - 'destination' => '/src/{{ spec.title | caseUcfirst}}/Services/{{service.name | caseUcfirst}}.php', - 'template' => 'php/src/Services/Service.php.twig', + 'scope' => 'service', + 'destination' => '/src/{{ spec.title | caseUcfirst}}/Services/{{service.name | caseUcfirst}}.php', + 'template' => 'php/src/Services/Service.php.twig', ], [ - 'scope' => 'service', - 'destination' => '/tests/{{ spec.title | caseUcfirst}}/Services/{{service.name | caseUcfirst}}Test.php', - 'template' => 'php/tests/Services/ServiceTest.php.twig', + 'scope' => 'service', + 'destination' => '/tests/{{ spec.title | caseUcfirst}}/Services/{{service.name | caseUcfirst}}Test.php', + 'template' => 'php/tests/Services/ServiceTest.php.twig', ], [ - 'scope' => 'enum', - 'destination' => '/src/{{ spec.title | caseUcfirst}}/Enums/{{ enum.name | caseUcfirst }}.php', - 'template' => 'php/src/Enums/Enum.php.twig', + 'scope' => 'enum', + 'destination' => '/src/{{ spec.title | caseUcfirst}}/Enums/{{ enum.name | caseUcfirst }}.php', + 'template' => 'php/src/Enums/Enum.php.twig', ], ]; } @@ -258,6 +258,8 @@ public function getTypeName(array $parameter, array $spec = []): string if (!empty($parameter['enumValues'])) { return \ucfirst($parameter['name']); } + + return match ($parameter['type']) { self::TYPE_STRING => 'string', self::TYPE_BOOLEAN => 'bool', @@ -265,7 +267,8 @@ public function getTypeName(array $parameter, array $spec = []): string self::TYPE_INTEGER => 'int', self::TYPE_ARRAY, self::TYPE_OBJECT => 'array', - self::TYPE_FILE => 'InputFile', + self::TYPE_FILE, + self::TYPE_PAYLOAD => 'Payload', default => $parameter['type'], }; } @@ -276,9 +279,9 @@ public function getTypeName(array $parameter, array $spec = []): string */ public function getParamDefault(array $param): string { - $type = $param['type'] ?? ''; - $default = $param['default'] ?? ''; - $required = $param['required'] ?? ''; + $type = $param['type'] ?? ''; + $default = $param['default'] ?? ''; + $required = $param['required'] ?? ''; if ($required) { return ''; @@ -329,8 +332,8 @@ public function getParamDefault(array $param): string */ public function getParamExample(array $param): string { - $type = $param['type'] ?? ''; - $example = $param['example'] ?? ''; + $type = $param['type'] ?? ''; + $example = $param['example'] ?? ''; $output = ''; @@ -348,8 +351,11 @@ public function getParamExample(array $param): string case self::TYPE_OBJECT: $output .= '[]'; break; + case self::TYPE_PAYLOAD: + $output .= "Payload::fromJson([ 'x' => 'y' ])"; + break; case self::TYPE_FILE: - $output .= "InputFile::withPath('file.png')"; + $output .= "Payload::fromFile('file.png')"; break; } } else { @@ -368,8 +374,11 @@ public function getParamExample(array $param): string case self::TYPE_STRING: $output .= "'{$example}'"; break; + case self::TYPE_PAYLOAD: + $output .= "Payload::fromJson([ 'x' => 'y' ])"; + break; case self::TYPE_FILE: - $output .= "InputFile::withPath('file.png')"; + $output .= "Payload::fromFile('file.png')"; break; } } diff --git a/src/SDK/Language/Python.php b/src/SDK/Language/Python.php index 72d87b664..cfb7c5839 100644 --- a/src/SDK/Language/Python.php +++ b/src/SDK/Language/Python.php @@ -157,8 +157,13 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => '{{ spec.title | caseSnake}}/input_file.py', - 'template' => 'python/package/input_file.py.twig', + 'destination' => '{{ spec.title | caseSnake}}/payload.py', + 'template' => 'python/package/payload.py.twig', + ], + [ + 'scope' => 'default', + 'destination' => '{{ spec.title | caseSnake}}/multipart.py', + 'template' => 'python/package/multipart.py.twig', ], [ 'scope' => 'default', @@ -232,7 +237,8 @@ public function getTypeName(array $parameter, array $spec = []): string return \ucfirst($parameter['name']); } return match ($parameter['type'] ?? '') { - self::TYPE_FILE => 'InputFile', + self::TYPE_PAYLOAD, + self::TYPE_FILE => 'Payload', self::TYPE_NUMBER, self::TYPE_INTEGER => 'float', self::TYPE_BOOLEAN => 'bool', @@ -276,6 +282,9 @@ public function getParamDefault(array $param): string case self::TYPE_FILE: $output .= '{}'; break; + case self::TYPE_PAYLOAD: + $output .= '{}'; + break; } } else { switch ($type) { @@ -288,6 +297,9 @@ public function getParamDefault(array $param): string case self::TYPE_FILE: $output .= '{}'; break; + case self::TYPE_PAYLOAD: + $output .= '{}'; + break; case self::TYPE_BOOLEAN: $output .= ($default) ? 'True' : 'False'; break; @@ -327,8 +339,11 @@ public function getParamExample(array $param): string case self::TYPE_OBJECT: $output .= '{}'; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.from_json({"x": "y"})'; + break; case self::TYPE_FILE: - $output .= "InputFile.from_path('file.png')"; + $output .= "Payload.from_file('/path/to/file.png')"; break; } } else { @@ -345,8 +360,11 @@ public function getParamExample(array $param): string case self::TYPE_STRING: $output .= "'{$example}'"; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.from_json({"x": "y"})'; + break; case self::TYPE_FILE: - $output .= "InputFile.from_path('file.png')"; + $output .= "Payload.from_file('/path/to/file.png')"; break; } } diff --git a/src/SDK/Language/ReactNative.php b/src/SDK/Language/ReactNative.php index d0b4cb8f6..3c5fd4019 100644 --- a/src/SDK/Language/ReactNative.php +++ b/src/SDK/Language/ReactNative.php @@ -2,8 +2,6 @@ namespace Appwrite\SDK\Language; -use Twig\TwigFilter; - class ReactNative extends Web { /** @@ -65,6 +63,16 @@ public function getFiles(): array 'destination' => 'src/query.ts', 'template' => 'react-native/src/query.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'src/payload.ts', + 'template' => 'react-native/src/payload.ts.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'src/multipart.ts', + 'template' => 'react-native/src/multipart.ts.twig', + ], [ 'scope' => 'default', 'destination' => 'README.md', @@ -145,8 +153,9 @@ public function getTypeName(array $parameter, array $spec = []): string return $this->getTypeName($parameter['array']) . '[]'; } return 'string[]'; + case self::TYPE_PAYLOAD: case self::TYPE_FILE: - return '{name: string, type: string, size: number, uri: string}'; + return 'Payload'; } return $parameter['type']; @@ -179,6 +188,7 @@ public function getParamExample(array $param): string case self::TYPE_OBJECT: $output .= '{}'; break; + case self::TYPE_PAYLOAD: case self::TYPE_FILE: $output .= "await pickSingle()"; break; @@ -197,6 +207,7 @@ public function getParamExample(array $param): string case self::TYPE_STRING: $output .= "'{$example}'"; break; + case self::TYPE_PAYLOAD: case self::TYPE_FILE: $output .= "await pickSingle()"; break; diff --git a/src/SDK/Language/Ruby.php b/src/SDK/Language/Ruby.php index 16125d52c..af6f0c9e4 100644 --- a/src/SDK/Language/Ruby.php +++ b/src/SDK/Language/Ruby.php @@ -154,8 +154,13 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => 'lib/{{ spec.title | caseDash }}/input_file.rb', - 'template' => 'ruby/lib/container/input_file.rb.twig', + 'destination' => 'lib/{{ spec.title | caseDash }}/payload.rb', + 'template' => 'ruby/lib/container/payload.rb.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'lib/{{ spec.title | caseDash }}/multipart.rb', + 'template' => 'ruby/lib/container/multipart.rb.twig', ], [ 'scope' => 'default', @@ -209,6 +214,8 @@ public function getTypeName(array $parameter, array $spec = []): string self::TYPE_STRING => 'String', self::TYPE_ARRAY => 'Array', self::TYPE_OBJECT => 'Hash', + self::TYPE_FILE => 'Payload', + self::TYPE_PAYLOAD => 'Payload', self::TYPE_BOOLEAN => '', default => $parameter['type'], }; @@ -294,8 +301,11 @@ public function getParamExample(array $param): string case self::TYPE_OBJECT: $output .= '{}'; break; + case self::TYPE_PAYLOAD: + $output .= "Payload.from_json({ \"x\": \"y\" })"; + break; case self::TYPE_FILE: - $output .= "InputFile.from_path('dir/file.png')"; + $output .= "Payload.from_file('/path/to/file.png')"; break; } } else { @@ -314,8 +324,11 @@ public function getParamExample(array $param): string case self::TYPE_STRING: $output .= "'{$example}'"; break; + case self::TYPE_PAYLOAD: + $output .= "Payload.from_json({ \"x\": \"y\" })"; + break; case self::TYPE_FILE: - $output .= "InputFile.from_path('dir/file.png')"; + $output .= "Payload.from_file('/path/to/file.png')"; break; } } diff --git a/src/SDK/Language/Web.php b/src/SDK/Language/Web.php index 968fc1103..7a97dfecf 100644 --- a/src/SDK/Language/Web.php +++ b/src/SDK/Language/Web.php @@ -30,16 +30,21 @@ public function getFiles(): array 'destination' => 'src/client.ts', 'template' => 'web/src/client.ts.twig', ], - [ - 'scope' => 'default', - 'destination' => 'src/service.ts', - 'template' => 'web/src/service.ts.twig', - ], [ 'scope' => 'service', 'destination' => 'src/services/{{service.name | caseDash}}.ts', 'template' => 'web/src/services/template.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'src/payload.ts', + 'template' => 'web/src/payload.ts.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'src/multipart.ts', + 'template' => 'web/src/multipart.ts.twig', + ], [ 'scope' => 'default', 'destination' => 'src/models.ts', @@ -150,8 +155,11 @@ public function getParamExample(array $param): string case self::TYPE_OBJECT: $output .= '{}'; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson({ x: "y" })'; + break; case self::TYPE_FILE: - $output .= "document.getElementById('uploader').files[0]"; + $output .= "Payload.fromFile(document.getElementById('uploader').files[0])"; break; } } else { @@ -168,8 +176,11 @@ public function getParamExample(array $param): string case self::TYPE_STRING: $output .= "'{$example}'"; break; + case self::TYPE_PAYLOAD: + $output .= 'Payload.fromJson({ x: "y" })'; + break; case self::TYPE_FILE: - $output .= "document.getElementById('uploader').files[0]"; + $output .= "Payload.fromFile(document.getElementById('uploader').files[0])"; break; } } @@ -195,7 +206,8 @@ public function getTypeName(array $parameter, array $method = []): string } return 'string[]'; case self::TYPE_FILE: - return 'File'; + case self::TYPE_PAYLOAD: + return 'Payload'; case self::TYPE_OBJECT: if (empty($method)) { return $parameter['type']; diff --git a/templates/android/docs/java/example.md.twig b/templates/android/docs/java/example.md.twig index f5f896685..a8d567982 100644 --- a/templates/android/docs/java/example.md.twig +++ b/templates/android/docs/java/example.md.twig @@ -1,7 +1,7 @@ import {{ sdk.namespace | caseDot }}.Client; import {{ sdk.namespace | caseDot }}.coroutines.CoroutineCallback; {% if method.parameters.all | filter((param) => param.type == 'file') | length > 0 %} -import {{ sdk.namespace | caseDot }}.models.InputFile; +import {{ sdk.namespace | caseDot }}.models.Payload; {% endif %} import {{ sdk.namespace | caseDot }}.services.{{ service.name | caseUcfirst }}; {% set added = [] %} @@ -66,4 +66,4 @@ Client client = new Client(context) ); {% endif %} -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/android/docs/kotlin/example.md.twig b/templates/android/docs/kotlin/example.md.twig index c15f3ee0b..e9c73b0b8 100644 --- a/templates/android/docs/kotlin/example.md.twig +++ b/templates/android/docs/kotlin/example.md.twig @@ -1,7 +1,7 @@ import {{ sdk.namespace | caseDot }}.Client import {{ sdk.namespace | caseDot }}.coroutines.CoroutineCallback {% if method.parameters.all | filter((param) => param.type == 'file') | length > 0 %} -import {{ sdk.namespace | caseDot }}.models.InputFile +import {{ sdk.namespace | caseDot }}.models.Payload {% endif %} import {{ sdk.namespace | caseDot }}.services.{{ service.name | caseUcfirst }} {% set added = [] %} diff --git a/templates/android/library/src/main/java/io/package/Client.kt.twig b/templates/android/library/src/main/java/io/package/Client.kt.twig index 7ddeac404..6f326e5f7 100644 --- a/templates/android/library/src/main/java/io/package/Client.kt.twig +++ b/templates/android/library/src/main/java/io/package/Client.kt.twig @@ -7,8 +7,9 @@ import {{ sdk.namespace | caseDot }}.cookies.ListenableCookieJar import {{ sdk.namespace | caseDot }}.cookies.stores.SharedPreferencesCookieStore import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception import {{ sdk.namespace | caseDot }}.extensions.fromJson +import {{ sdk.namespace | caseDot }}.extensions.fromMultiPart import {{ sdk.namespace | caseDot }}.extensions.toJson -import {{ sdk.namespace | caseDot }}.models.InputFile +import {{ sdk.namespace | caseDot }}.models.Payload import {{ sdk.namespace | caseDot }}.models.UploadProgress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -60,7 +61,7 @@ class Client @JvmOverloads constructor( internal lateinit var http: OkHttpClient internal val headers: MutableMap - + val config: MutableMap internal val cookieJar = ListenableCookieJar(CookieManager( @@ -87,14 +88,14 @@ class Client @JvmOverloads constructor( "x-sdk-platform" to "{{ sdk.platform }}", "x-sdk-language" to "{{ language.name | caseLower }}", "x-sdk-version" to "{{ sdk.version }}"{% if spec.global.defaultHeaders | length > 0 %},{% endif %} - + {% for key,header in spec.global.defaultHeaders %} "{{ key | caseLower }}" to "{{ header }}"{% if not loop.last %},{% endif %} {% endfor %} ) config = mutableMapOf() - + setSelfSigned(selfSigned) } @@ -119,10 +120,10 @@ class Client @JvmOverloads constructor( {% endfor %} /** * Set self Signed - * + * * @param status * - * @return this + * @return this */ fun setSelfSigned(status: Boolean): Client { selfSigned = status @@ -171,10 +172,10 @@ class Client @JvmOverloads constructor( /** * Set endpoint and realtime endpoint. - * + * * @param endpoint * - * @return this + * @return this */ fun setEndpoint(endpoint: String): Client { this.endpoint = endpoint @@ -200,11 +201,11 @@ class Client @JvmOverloads constructor( /** * Add Header - * + * * @param key * @param value * - * @return this + * @return this */ fun addHeader(key: String, value: String): Client { headers[key] = value @@ -213,19 +214,19 @@ class Client @JvmOverloads constructor( /** * Send the HTTP request - * + * * @param method * @param path * @param headers * @param params * - * @return [T] + * @return [T] */ @Throws({{ spec.title | caseUcfirst }}Exception::class) suspend fun call( - method: String, - path: String, - headers: Map = mapOf(), + method: String, + path: String, + headers: Map = mapOf(), params: Map = mapOf(), responseType: Class, converter: ((Any) -> T)? = null @@ -284,6 +285,14 @@ class Client @JvmOverloads constructor( ) } } + it.value is Payload -> { + val payload = it.value as Payload + if (payload.sourceType == "path") { + builder.addFormDataPart(it.key, payload.filename, File(payload.path).asRequestBody()) + } else { + builder.addFormDataPart(it.key, payload.toString()) + } + } else -> { builder.addFormDataPart(it.key, it.value.toString()) } @@ -326,14 +335,14 @@ class Client @JvmOverloads constructor( onProgress: ((UploadProgress) -> Unit)? = null, ): T { var file: RandomAccessFile? = null - val input = params[paramName] as InputFile + val input = params[paramName] as Payload val size: Long = when(input.sourceType) { "path", "file" -> { file = RandomAccessFile(input.path, "r") file.length() } "bytes" -> { - (input.data as ByteArray).size.toLong() + input.toBinary().size.toLong() } else -> throw UnsupportedOperationException() } @@ -341,7 +350,7 @@ class Client @JvmOverloads constructor( if (size < CHUNK_SIZE) { val data = when(input.sourceType) { "file", "path" -> File(input.path).asRequestBody() - "bytes" -> (input.data as ByteArray).toRequestBody(input.mimeType.toMediaType()) + "bytes" -> input.toBinary().toRequestBody(input.mimeType?.toMediaType()) else -> throw UnsupportedOperationException() } params[paramName] = MultipartBody.Part.createFormData( @@ -388,7 +397,7 @@ class Client @JvmOverloads constructor( } else { size - 1 } - (input.data as ByteArray).copyInto( + input.toBinary().copyInto( buffer, startIndex = offset.toInt(), endIndex = end.toInt() @@ -460,14 +469,14 @@ class Client @JvmOverloads constructor( .charStream() .buffered() .use(BufferedReader::readText) - + val error = if (response.headers["content-type"]?.contains("application/json") == true) { val map = body.fromJson>() {{ spec.title | caseUcfirst }}Exception( - map["message"] as? String ?: "", + map["message"] as? String ?: "", (map["code"] as Number).toInt(), - map["type"] as? String ?: "", + map["type"] as? String ?: "", body ) } else { @@ -502,6 +511,14 @@ class Client @JvmOverloads constructor( return } } + if (response.headers["content-type"]?.contains("multipart/form-data") == true) { + val binaryBody = response.body!!.bytes() + val body = String(binaryBody) + val map = body.fromMultiPart(binaryBody) + it.resume(converter?.invoke(map) ?: map as T) + return + } + val body = response.body!! .charStream() .buffered() @@ -519,4 +536,4 @@ class Client @JvmOverloads constructor( } }) } -} \ No newline at end of file +} diff --git a/templates/android/library/src/main/java/io/package/extensions/TypeExtensions.kt.twig b/templates/android/library/src/main/java/io/package/extensions/TypeExtensions.kt.twig index ee0a6a14d..3974500a2 100644 --- a/templates/android/library/src/main/java/io/package/extensions/TypeExtensions.kt.twig +++ b/templates/android/library/src/main/java/io/package/extensions/TypeExtensions.kt.twig @@ -1,9 +1,104 @@ package {{ sdk.namespace | caseDot }}.extensions +import {{ sdk.namespace | caseDot }}.models.Payload import kotlin.reflect.KClass import kotlin.reflect.typeOf inline fun classOf(): Class { - @Suppress("UNCHECKED_CAST") - return (typeOf().classifier!! as KClass).java -} \ No newline at end of file + @Suppress("UNCHECKED_CAST") return (typeOf().classifier!! as KClass).java +} + +fun String.fromMultiPart(binaryBody: ByteArray): Map { + val match = Regex("(-+\\w+)--").find(this) ?: return emptyMap() + // For kotlin + + val boundary = match.groupValues[1] + + var map = + mutableMapOf( + "\$id" to "", + "\$createdAt" to "", + "\$updatedAt" to "", + "\$permissions" to emptyList(), + "functionId" to "", + "trigger" to "", + "status" to "", + "requestMethod" to "", + "requestPath" to "", + "requestHeaders" to emptyList>(), + "responseStatusCode" to 0, + "responseBody" to Payload.fromBinary(ByteArray(0)), + "responseHeaders" to emptyList>(), + "logs" to "", + "errors" to "", + "duration" to 0.0, + "scheduledAt" to "", + ) + val parts = this.split(boundary) + for (part in parts) { + var lines = part.split("\r\n") + + val name = Regex("name=\"?(\\w+)").find(part) ?: continue + + lines = + lines + .dropWhile { it.isEmpty() } + .drop(1) + .dropWhile { it.isEmpty() } + .dropLastWhile { it.isEmpty() } + val key = name.groupValues[1] + + if (lines.isEmpty()) { + continue + } + + if (key == "responseBody") { + val needle = "name=\"responseBody\"\r\n\r\n" + val indexOf = this.indexOf(needle) + needle.length + val endBytes = "\r\n-------".toByteArray() + val list = ByteArray(binaryBody.size - indexOf) + val multipart = binaryBody.drop(indexOf) + var weHitTheEnd = false + var j = 0 + for (i in multipart) { + if (multipart.size > j + endBytes.size) { + var jj = 0 + for (byte in endBytes) { + if (byte != multipart[j + jj]) break + jj++ + if (jj != endBytes.size - 1) continue + weHitTheEnd = true + } + } + if (weHitTheEnd) { + break + } + + list[j] = multipart[j] + j++ + } + + map["responseBody"] = + Payload.fromBinary(list.dropLastWhile { it == 0.toByte() }.toByteArray()) + continue + } + + if (lines[0] == "Content-Type: application/json") { + lines = lines.drop(1).dropWhile { it.isEmpty() } + val list = lines.joinToString("\r\n").fromJson>() + map[key] = list + continue + } + + val value = lines.joinToString("\r\n") + + map[key] = + when (key) { + "responseStatusCode" -> value.toInt() + "duration" -> value.toFloat() + else -> value + } + } + + return map +} diff --git a/templates/android/library/src/main/java/io/package/models/InputFile.kt.twig b/templates/android/library/src/main/java/io/package/models/InputFile.kt.twig deleted file mode 100644 index 382267a0d..000000000 --- a/templates/android/library/src/main/java/io/package/models/InputFile.kt.twig +++ /dev/null @@ -1,37 +0,0 @@ -package {{ sdk.namespace | caseDot }}.models - -import java.io.File -import java.net.URLConnection -import java.nio.file.Files -import java.nio.file.Paths - -class InputFile private constructor() { - - lateinit var path: String - lateinit var filename: String - lateinit var mimeType: String - lateinit var sourceType: String - lateinit var data: Any - - companion object { - fun fromFile(file: File) = InputFile().apply { - path = file.canonicalPath - filename = file.name - mimeType = Files.probeContentType(Paths.get(file.canonicalPath)) - ?: URLConnection.guessContentTypeFromName(filename) - ?: "" - sourceType = "file" - } - - fun fromPath(path: String): InputFile = fromFile(File(path)).apply { - sourceType = "path" - } - - fun fromBytes(bytes: ByteArray, filename: String = "", mimeType: String = "") = InputFile().apply { - this.filename = filename - this.mimeType = mimeType - data = bytes - sourceType = "bytes" - } - } -} \ No newline at end of file diff --git a/templates/android/library/src/main/java/io/package/models/Payload.kt.twig b/templates/android/library/src/main/java/io/package/models/Payload.kt.twig new file mode 100644 index 000000000..fb9ff049d --- /dev/null +++ b/templates/android/library/src/main/java/io/package/models/Payload.kt.twig @@ -0,0 +1,73 @@ +package {{ sdk.namespace | caseDot }}.models + +import {{ sdk.namespace | caseDot }}.extensions.gson +import java.io.File +import java.net.URLConnection +import java.nio.file.Files +import java.nio.file.Paths + +class Payload private constructor() { + + lateinit var path: String + lateinit var filename: String + lateinit var sourceType: String + lateinit var data: Any + var mimeType: String? = null + + override fun toString(): String { + if (sourceType != "bytes") { + throw IllegalArgumentException("source type is not supported: $sourceType") + } + + return String(data as ByteArray) + } + + fun toBinary(): ByteArray { + if (sourceType != "bytes") { + throw IllegalArgumentException("source type is not supported: $sourceType") + } + + return data as ByteArray + } + + fun toJson(): MutableMap { + if (sourceType != "bytes") { + throw IllegalArgumentException("source type is not supported: $sourceType") + } + + return gson.fromJson(toString(), MutableMap::class.java) as MutableMap + } + + fun toFile(path: String): File { + Files.createDirectories(Paths.get(path).parent); + + val file = File(path) + file.appendBytes(toBinary()) + return file + } + + companion object { + fun fromFile(path: String,filename: String = ""): Payload = fromFileObject(File(path), filename).apply { + sourceType = "path" + } + + fun fromBinary(bytes: ByteArray, filename: String = "") = Payload().apply { + this.filename = filename + data = bytes + sourceType = "bytes" + } + + fun fromString(string: String) = fromBinary(string.toByteArray()) + + fun fromJson(data: Any) = fromString(gson.toJson(data)) + + fun fromFileObject(file: File, name: String = "") = Payload().apply { + path = file.canonicalPath + filename = if (name != "") name else file.name + mimeType = Files.probeContentType(Paths.get(file.canonicalPath)) + ?: URLConnection.guessContentTypeFromName(filename) + ?: "" + sourceType = "file" + } + } +} 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 ef94f0d88..3084ef20a 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 @@ -53,7 +53,7 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { {%~ if method.responseModel | hasGenericType(spec) %} nestedType: Class, {%~ endif %} - {%~ if 'multipart/form-data' in method.consumes %} + {% if 'multipart/form-data' in method.consumes and method.type == "upload" %} onProgress: ((UploadProgress) -> Unit)? = null {%~ endif %} ){% if method.type != "webAuth" %}: {{ method | returnType(spec, sdk.namespace | caseDot) | raw }}{% endif %} { @@ -64,7 +64,11 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { val apiParams = mutableMapOf( {%~ for parameter in method.parameters.query | merge(method.parameters.body) %} - "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, + {% if 'multipart/form-data' in method.consumes and method.type != "upload" and parameter.name == 'body' %} + "{{ parameter.name }}" to ({{ parameter.name | caseCamel }}?.toBinary() ?: ""), + {%~ else %} + "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, + {%~ endif %} {%~ endfor %} {%~ if method.type == 'location' or method.type == 'webAuth' %} {%~ if method.auth | length > 0 %} @@ -115,7 +119,7 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { .domain(Uri.parse(client.endpoint).host!!) .httpOnly() .build() - + client.http.cookieJar.saveFromResponse( client.endpoint.toHttpUrl(), listOf(cookie) @@ -129,9 +133,12 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { responseType = {{ method | returnType(spec, sdk.namespace | caseDot) | raw }}::class.java ) {%~ else %} - val apiHeaders = mutableMapOf( + val apiHeaders = mutableMapOf( {%~ for key, header in method.headers %} "{{ key }}" to "{{ header }}", + {%~ if 'multipart/form-data' in method.consumes and method.type != "upload" %} + "accept" to "multipart/form-data", + {%~ endif %} {%~ endfor %} ) {%~ if method.responseModel %} @@ -144,9 +151,9 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { {%~ endif %} } {%~ endif %} - {%~ if 'multipart/form-data' in method.consumes %} + {% if 'multipart/form-data' in method.consumes and method.type == "upload" %} val idParamName: String? = {% if method.parameters.all | filter(p => p.isUploadID) | length > 0 %}{% for parameter in method.parameters.all | filter(parameter => parameter.isUploadID) %}"{{ parameter.name }}"{% endfor %}{% else %}null{% endif %} - + {%~ for parameter in method.parameters.all %} {%~ if parameter.type == 'file' %} val paramName = "{{ parameter.name }}" @@ -207,7 +214,7 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { {%~ for parameter in method.parameters.all %} {{ parameter.name | caseCamel }}: {{ parameter | typeName }}{%~ if not parameter.required or parameter.nullable %}? = null{% endif %}, {%~ endfor %} - {%~ if 'multipart/form-data' in method.consumes %} + {% if 'multipart/form-data' in method.consumes and method.type == "upload" %} onProgress: ((UploadProgress) -> Unit)? = null {%~ endif %} ): {{ method | returnType(spec, sdk.namespace | caseDot, 'Map') | raw }} = {{ method.name | caseCamel }}( @@ -220,11 +227,11 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { {%~ if method.responseModel | hasGenericType(spec) %} nestedType = classOf(), {%~ endif %} - {%~ if 'multipart/form-data' in method.consumes %} + {% if 'multipart/form-data' in method.consumes and method.type == "upload" %} onProgress = onProgress {%~ endif %} ) {%~ endif %} {%~ endfor %} -} \ No newline at end of file +} diff --git a/templates/dart/lib/models.dart.twig b/templates/dart/lib/models.dart.twig index 1a15137f2..71f1c1daa 100644 --- a/templates/dart/lib/models.dart.twig +++ b/templates/dart/lib/models.dart.twig @@ -1,6 +1,8 @@ /// {{spec.title | caseUcfirst}} Models library {{ language.params.packageName }}.models; +import 'payload.dart'; + part 'src/models/model.dart'; {% for definition in spec.definitions %} part 'src/models/{{definition.name | caseSnake}}.dart'; diff --git a/templates/dart/lib/package.dart.twig b/templates/dart/lib/package.dart.twig index 3c67fdffe..72cc1f321 100644 --- a/templates/dart/lib/package.dart.twig +++ b/templates/dart/lib/package.dart.twig @@ -12,16 +12,16 @@ import 'dart:convert'; import 'src/enums.dart'; import 'src/service.dart'; -import 'src/input_file.dart'; import 'src/upload_progress.dart'; import 'models.dart' as models; import 'enums.dart' as enums; +import 'payload.dart'; export 'src/response.dart'; export 'src/client.dart'; export 'src/exception.dart'; -export 'src/input_file.dart'; export 'src/upload_progress.dart'; +export 'payload.dart'; part 'query.dart'; part 'permission.dart'; diff --git a/templates/dart/lib/payload.dart.twig b/templates/dart/lib/payload.dart.twig new file mode 100644 index 000000000..12d99ae71 --- /dev/null +++ b/templates/dart/lib/payload.dart.twig @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'src/exception.dart'; +import 'dart:io'; + +class Payload { + late final String? path; + late final List? data; + final String? filename; + + Payload._({this.path, this.filename, this.data}) { + if (path == null && data == null) { + throw {{spec.title | caseUcfirst}}Exception('One of `path` or `data` is required'); + } + } + + /// Convert to binary, with optional offset and length + List toBinary({int offset = 0, int? length}) { + if(data == null) { + throw {{spec.title | caseUcfirst}}Exception('`data` is not defined.'); + } + if(offset == 0 && length == null) { + return data!; + } else if (length == null) { + return data!.sublist(offset); + } else { + return data!.sublist(offset, offset + length); + } + } + + /// Convert binary data to string (utf8) + @override + String toString() { + if(data == null) { + return ''; + } + return utf8.decode(data!); + } + + /// Convert binary data to JSON object + Map toJson() { + try { + return jsonDecode(toString()); // Decode the string to JSON + } catch (e) { + throw FormatException('Failed to parse JSON: ${e.toString()}'); + } + } + + /// Create a file from the payload + void toFile(String path) { + if(data == null) { + throw {{spec.title | caseUcfirst}}Exception('`data` is not defined.'); + } + final file = File(path); + file.writeAsBytesSync(data!); + } + + /// Create a Payload from binary data + factory Payload.fromBinary({ + required List data, + String? filename, + }) { + return Payload._(data: data, filename: filename); + } + + /// Create a Payload from a file + factory Payload.fromFile({required String path, String? filename}) { + return Payload._(path: path, filename: filename); + } + + /// Create a Payload from a JSON object + factory Payload.fromJson({ + required Map data, + String? filename, + }) { + final jsonString = jsonEncode(data); + return Payload.fromString(string: jsonString, filename: filename); + } + + /// Create a Payload from a string + factory Payload.fromString({required String string, String? filename}) { + final data = utf8.encode(string); + return Payload._(data: data, filename: filename); + } +} diff --git a/templates/dart/lib/services/service.dart.twig b/templates/dart/lib/services/service.dart.twig index ae930dbba..73e626379 100644 --- a/templates/dart/lib/services/service.dart.twig +++ b/templates/dart/lib/services/service.dart.twig @@ -22,7 +22,7 @@ class {{ service.name | caseUcfirst }} extends Service { {% if method.type == 'location' %}Future{% else %}{% if method.responseModel and method.responseModel != 'any' %}Future{% else %}Future{% endif %}{% endif %} {{ method.name | caseCamel | overrideIdentifier }}({{ _self.method_parameters(method.parameters.all, method.consumes) }}) async { final String apiPath = '{{ method.path }}'{% for parameter in method.parameters.path %}.replaceAll('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', {{ parameter.name | caseCamel | overrideIdentifier }}{% if parameter.enumValues | length > 0 %}.value{% endif %}){% endfor %}; -{% if 'multipart/form-data' in method.consumes %} +{%~ if 'multipart/form-data' in method.consumes and method.type == "upload" %} {{ include('dart/base/requests/file.twig') }} {% elseif method.type == 'location' %} {{ include('dart/base/requests/location.twig') }} diff --git a/templates/dart/lib/src/client_browser.dart.twig b/templates/dart/lib/src/client_browser.dart.twig index 6a0f046fd..b7fc8f56f 100644 --- a/templates/dart/lib/src/client_browser.dart.twig +++ b/templates/dart/lib/src/client_browser.dart.twig @@ -6,7 +6,7 @@ import 'enums.dart'; import 'exception.dart'; import 'client_base.dart'; import 'response.dart'; -import 'input_file.dart'; +import '../payload.dart'; import 'upload_progress.dart'; ClientBase createClient({ @@ -94,16 +94,16 @@ class ClientBrowser extends ClientBase with ClientMixin { required Map headers, Function(UploadProgress)? onProgress, }) async { - InputFile file = params[paramName]; - if (file.bytes == null) { - throw {{spec.title | caseUcfirst}}Exception("File bytes must be provided for Flutter web"); + Payload file = params[paramName]; + if (file.data == null) { + throw {{spec.title | caseUcfirst}}Exception("File data must be provided for Flutter web"); } - int size = file.bytes!.length; + int size = file.data!.length; late Response res; if (size <= CHUNK_SIZE) { - params[paramName] = http.MultipartFile.fromBytes(paramName, file.bytes!, filename: file.filename); + params[paramName] = http.MultipartFile.fromBytes(paramName, file.data!, filename: file.filename); return call( HttpMethod.post, path: path, @@ -129,7 +129,7 @@ class ClientBrowser extends ClientBase with ClientMixin { while (offset < size) { List chunk = []; final end = min(offset + CHUNK_SIZE, size); - chunk = file.bytes!.getRange(offset, end).toList(); + chunk = file.toBinary(length: offset, length: min(CHUNK_SIZE, size - offset)); params[paramName] = http.MultipartFile.fromBytes(paramName, chunk, filename: file.filename); headers['content-range'] = diff --git a/templates/dart/lib/src/client_io.dart.twig b/templates/dart/lib/src/client_io.dart.twig index 8a51e5979..07b8ee86d 100644 --- a/templates/dart/lib/src/client_io.dart.twig +++ b/templates/dart/lib/src/client_io.dart.twig @@ -2,12 +2,13 @@ import 'dart:io'; import 'dart:math'; import 'package:http/http.dart' as http; import 'package:http/io_client.dart'; + import 'client_mixin.dart'; import 'client_base.dart'; import 'enums.dart'; import 'exception.dart'; import 'response.dart'; -import 'input_file.dart'; +import '../payload.dart'; import 'upload_progress.dart'; ClientBase createClient({ @@ -98,14 +99,14 @@ class ClientIO extends ClientBase with ClientMixin { required Map headers, Function(UploadProgress)? onProgress, }) async { - InputFile file = params[paramName]; - if (file.path == null && file.bytes == null) { - throw {{spec.title | caseUcfirst}}Exception("File path or bytes must be provided"); + Payload file = params[paramName]; + if (file.path == null && file.data == null) { + throw {{spec.title | caseUcfirst}}Exception("File path or data must be provided"); } int size = 0; - if (file.bytes != null) { - size = file.bytes!.length; + if (file.data != null) { + size = file.data!.length; } File? iofile; @@ -122,7 +123,7 @@ class ClientIO extends ClientBase with ClientMixin { paramName, file.path!, filename: file.filename); } else { - params[paramName] = http.MultipartFile.fromBytes(paramName, file.bytes!, + params[paramName] = http.MultipartFile.fromBytes(paramName, file.data!, filename: file.filename); } return call( @@ -155,9 +156,8 @@ class ClientIO extends ClientBase with ClientMixin { while (offset < size) { List chunk = []; - if (file.bytes != null) { - final end = min(offset + CHUNK_SIZE, size); - chunk = file.bytes!.getRange(offset, end).toList(); + if (file.data != null) { + chunk = file.toBinary(offset: offset, length: min(CHUNK_SIZE, size - offset)); } else { raf!.setPositionSync(offset); chunk = raf.readSync(CHUNK_SIZE); diff --git a/templates/dart/lib/src/client_mixin.dart.twig b/templates/dart/lib/src/client_mixin.dart.twig index dacaa01b4..89d595824 100644 --- a/templates/dart/lib/src/client_mixin.dart.twig +++ b/templates/dart/lib/src/client_mixin.dart.twig @@ -1,11 +1,21 @@ import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:mime/mime.dart'; +import 'package:string_scanner/string_scanner.dart'; + import 'exception.dart'; import 'response.dart'; import 'dart:convert'; import 'dart:developer'; import 'enums.dart'; +import '../payload.dart'; + +mixin ClientMixin { + final _token = RegExp(r'[^()<>@,;:"\\/[\]?={} \t\x00-\x1F\x7F]+'); + final _whitespace = RegExp(r'(?:(?:\r\n)?[ \t]+)*'); + final _quotedString = RegExp(r'"(?:[^"\x00-\x1F\x7F]|\\.)*"'); + final _quotedPair = RegExp(r'\\(.)'); -class ClientMixin { http.BaseRequest prepareRequest( HttpMethod method, { required Uri uri, @@ -66,7 +76,7 @@ class ClientMixin { return request; } - Response prepareResponse(http.Response res, {ResponseType? responseType}) { + Future prepareResponse(http.Response res, {ResponseType? responseType}) async { responseType ??= ResponseType.json; String? warnings = res.headers['x-{{ spec.title | lower }}-warning']; @@ -96,6 +106,9 @@ class ClientMixin { } else { data = res.body; } + } else if((res.headers['content-type'] ?? '').contains('multipart/form-data')) { + data = await _parseMultipart(res.headers['content-type']!, Stream.value(res.bodyBytes)); + return Response(data: data); } else { if (responseType == ResponseType.bytes) { data = res.bodyBytes; @@ -120,4 +133,119 @@ class ClientMixin { return await http.Response.fromStream(streamedResponse); } } + + Future> _decodeMimeMultipart(MimeMultipart part) async { + List result = []; + + await for (var chunk in part) { + result.addAll(chunk); + } + + return result; + } + + /// Parse multipart forma data + Future> _parseMultipart( + String header, Stream> body) async { + final data = await _parts(header, body) + .map<_FormData?>((part) { + final rawDisposition = part.headers['content-disposition']; + if (rawDisposition == null) return null; + + final formDataParams = + _parseFormDataContentDisposition(rawDisposition); + if (formDataParams == null) return null; + + final name = formDataParams['name']; + if (name == null) return null; + + final filename = formDataParams['filename']; + dynamic value; + if (name == 'responseBody') { + return _FormData._(name, filename, part); + } else if (filename != null) { + value = { + "file": part, + "filename": filename, + "mimeType": part.headers['Content-Type'], + }; + } else { + value = utf8.decodeStream(part); + } + return _FormData._(name, filename, value); + }) + .where((data) => data != null) + .toList(); + final Map out = {}; + for (final item in data) { + if (item!.name == 'responseBody') { + out[item.name] = + Payload.fromBinary(data: await _decodeMimeMultipart(item.value), filename: item.filename); + } else { + out[item.name] = await item.value; + } + } + return out; + } + + Stream _parts(String header, Stream> body) { + final boundary = _extractBoundary(header); + if (boundary == null) { + throw Exception('Not a multipart request'); + } + return MimeMultipartTransformer(boundary).bind(body!); + } + + String? _extractBoundary(String header) { + final contentType = MediaType.parse(header); + if (contentType.type != 'multipart') return null; + + return contentType.parameters['boundary']; + } + + /// Parses a `content-disposition: form-data; arg1="val1"; ...` header. + Map? _parseFormDataContentDisposition(String header) { + final scanner = StringScanner(header); + + scanner + ..scan(_whitespace) + ..expect(_token); + if (scanner.lastMatch![0] != 'form-data') return null; + + final params = {}; + + while (scanner.scan(';')) { + scanner + ..scan(_whitespace) + ..scan(_token); + final key = scanner.lastMatch![0]!; + scanner.expect('='); + + String value; + if (scanner.scan(_token)) { + value = scanner.lastMatch![0]!; + } else { + scanner.expect(_quotedString, name: 'quoted string'); + final string = scanner.lastMatch![0]!; + + value = string + .substring(1, string.length - 1) + .replaceAllMapped(_quotedPair, (match) => match[1]!); + } + + scanner.scan(_whitespace); + params[key] = value; + } + + scanner.expectDone(); + return params; + } } + +class _FormData { + final String name; + final dynamic value; + final String? filename; + + _FormData._(this.name, this.filename, this.value); +} \ No newline at end of file diff --git a/templates/dart/lib/src/client_stub.dart.twig b/templates/dart/lib/src/client_stub.dart.twig index 95e9d217a..40423b492 100644 --- a/templates/dart/lib/src/client_stub.dart.twig +++ b/templates/dart/lib/src/client_stub.dart.twig @@ -1,6 +1,6 @@ import 'client_base.dart'; -/// Implemented in `browser_client.dart` and `io_client.dart`. +/// Implemented in `client_browser.dart` and `client_io.dart`. ClientBase createClient({required String endPoint, required bool selfSigned}) => throw UnsupportedError( 'Cannot create a client without dart:html or dart:io.'); diff --git a/templates/dart/lib/src/input_file.dart.twig b/templates/dart/lib/src/input_file.dart.twig deleted file mode 100644 index 70c00bc24..000000000 --- a/templates/dart/lib/src/input_file.dart.twig +++ /dev/null @@ -1,48 +0,0 @@ -import 'exception.dart'; - -/// Helper class to handle files. -class InputFile { - late final String? path; - late final List? bytes; - final String? filename; - final String? contentType; - - @Deprecated('Use `InputFile.fromPath` or `InputFile.fromBytes` instead.') - InputFile({this.path, this.filename, this.contentType, this.bytes}) { - if (path == null && bytes == null) { - throw {{ spec.title | caseUcfirst }}Exception('One of `path` or `bytes` is required'); - } - } - - InputFile._({this.path, this.filename, this.contentType, this.bytes}) { - if (path == null && bytes == null) { - throw {{ spec.title | caseUcfirst }}Exception('One of `path` or `bytes` is required'); - } - } - - /// Provide a file using `path` - factory InputFile.fromPath({ - required String path, - String? filename, - String? contentType, - }) { - return InputFile._( - path: path, - filename: filename, - contentType: contentType, - ); - } - - /// Provide a file using `bytes` - factory InputFile.fromBytes({ - required List bytes, - required String filename, - String? contentType, - }) { - return InputFile._( - bytes: bytes, - filename: filename, - contentType: contentType, - ); - } -} diff --git a/templates/dart/lib/src/models/model.dart.twig b/templates/dart/lib/src/models/model.dart.twig index 3ce581e20..961b8bfb1 100644 --- a/templates/dart/lib/src/models/model.dart.twig +++ b/templates/dart/lib/src/models/model.dart.twig @@ -32,7 +32,14 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {{property.sub_schema | caseUcfirst | overrideIdentifier}}.fromMap(map['{{property.name | escapeDollarSign }}']) {%- endif -%} {%- else -%} + {%- if property.type == "integer" -%} + (map['{{property.name | escapeDollarSign }}'] is String) ? + int.tryParse(map['{{property.name | escapeDollarSign }}']) {%- if property.required %} ?? 0{% endif %}: + {%- endif -%} map['{{property.name | escapeDollarSign }}'] + {%- if property.type == "integer" -%} + {%- if property.required %} ?? 0{% endif %} + {%- endif -%} {%- if property.type == "number" -%} {%- if not property.required %}?{% endif %}.toDouble() {%- endif -%} diff --git a/templates/dart/pubspec.yaml.twig b/templates/dart/pubspec.yaml.twig index 1b50207a2..07c2ede0d 100644 --- a/templates/dart/pubspec.yaml.twig +++ b/templates/dart/pubspec.yaml.twig @@ -6,9 +6,12 @@ repository: https://github.com/{{sdk.gitUserName}}/{{sdk.gitRepoName}} issue_tracker: https://github.com/appwrite/sdk-generator/issues documentation: {{ spec.contactURL }} environment: - sdk: '>=2.17.0 <4.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: http: '>=0.13.6 <2.0.0' + http_parser: ^4.1.0 + mime: ^1.0.6 + string_scanner: ^1.3.0 dev_dependencies: lints: ^4.0.0 diff --git a/templates/dart/test/services/service_test.dart.twig b/templates/dart/test/services/service_test.dart.twig index b5997121a..fce004cbf 100644 --- a/templates/dart/test/services/service_test.dart.twig +++ b/templates/dart/test/services/service_test.dart.twig @@ -96,7 +96,7 @@ void main() { {%~ endif ~%} final response = await {{service.name | caseCamel}}.{{method.name | caseCamel}}({%~ for parameter in method.parameters.all | filter((param) => param.required) ~%} - {{parameter.name | escapeKeyword | caseCamel}}: {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}InputFile.fromPath(path: './image.png'){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%},{%~ endfor ~%} + {{parameter.name | escapeKeyword | caseCamel}}: {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}Payload.fromPath(path: './image.png'){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%},{%~ endfor ~%} ); {%- if method.type == 'location' ~%} diff --git a/templates/dart/test/src/input_file_test.dart.twig b/templates/dart/test/src/input_file_test.dart.twig deleted file mode 100644 index c51938804..000000000 --- a/templates/dart/test/src/input_file_test.dart.twig +++ /dev/null @@ -1,51 +0,0 @@ -{% if 'dart' in language.params.packageName %} -import 'package:test/test.dart'; -{% else %} -import 'package:flutter_test/flutter_test.dart'; -{% endif %} -import 'package:{{language.params.packageName}}/src/exception.dart'; -import 'package:{{language.params.packageName}}/src/input_file.dart'; - -void main() { - group('InputFile', () { - test('throws exception when neither path nor bytes are provided', () { - expect( - () => InputFile(), - throwsA(isA<{{spec.title | caseUcfirst}}Exception>().having( - (e) => e.message, - 'message', - 'One of `path` or `bytes` is required', - )), - ); - }); - - test('throws exception when path and bytes are both null', () { - expect( - () => InputFile(path: null, bytes: null), - throwsA(isA<{{spec.title | caseUcfirst}}Exception>().having( - (e) => e.message, - 'message', - 'One of `path` or `bytes` is required', - )), - ); - }); - - test('creates InputFile from path', () { - final inputFile = InputFile.fromPath(path: '/path/to/file'); - - expect(inputFile.path, '/path/to/file'); - expect(inputFile.filename, isNull); - expect(inputFile.contentType, isNull); - expect(inputFile.bytes, isNull); - }); - - test('creates InputFile from bytes', () { - final inputFile = InputFile.fromBytes(bytes: [1, 2, 3], filename: 'file.txt'); - - expect(inputFile.path, isNull); - expect(inputFile.filename, 'file.txt'); - expect(inputFile.contentType, isNull); - expect(inputFile.bytes, [1, 2, 3]); - }); - }); -} diff --git a/templates/dart/test/src/payload_test.dart.twig b/templates/dart/test/src/payload_test.dart.twig new file mode 100644 index 000000000..95997cb70 --- /dev/null +++ b/templates/dart/test/src/payload_test.dart.twig @@ -0,0 +1,27 @@ +{% if 'dart' in language.params.packageName %} +import 'package:test/test.dart'; +{% else %} +import 'package:flutter_test/flutter_test.dart'; +{% endif %} +import 'package:{{language.params.packageName}}/src/exception.dart'; +import 'package:{{language.params.packageName}}/payload.dart'; + +void main() { + group('Payload', () { + test('creates Payload from path', () { + final payload = Payload.fromFile(path: '/path/to/file'); + + expect(payload.path, '/path/to/file'); + expect(payload.filename, isNull); + expect(payload.data, isNull); + }); + + test('creates Payload from binary', () { + final payload = Payload.fromBinary(data: [1, 2, 3], filename: 'file.txt'); + + expect(payload.path, isNull); + expect(payload.filename, 'file.txt'); + expect(payload.data, [1, 2, 3]); + }); + }); +} diff --git a/templates/deno/mod.ts.twig b/templates/deno/mod.ts.twig index ecebed34d..dd67141ef 100644 --- a/templates/deno/mod.ts.twig +++ b/templates/deno/mod.ts.twig @@ -3,7 +3,7 @@ import { Query } from "./src/query.ts"; import { Permission } from "./src/permission.ts"; import { Role } from "./src/role.ts"; import { ID } from "./src/id.ts"; -import { InputFile } from "./src/inputFile.ts"; +import { Payload } from "./src/payload.ts"; import { {{spec.title | caseUcfirst}}Exception } from "./src/exception.ts"; {% for service in spec.services %} import { {{service.name | caseUcfirst}} } from "./src/services/{{service.name | caseDash}}.ts"; @@ -18,7 +18,7 @@ export { Permission, Role, ID, - InputFile, + Payload, {{spec.title | caseUcfirst}}Exception, {% for service in spec.services %} {{service.name | caseUcfirst}}, diff --git a/templates/deno/src/client.ts.twig b/templates/deno/src/client.ts.twig index 754a6b872..4993a36b4 100644 --- a/templates/deno/src/client.ts.twig +++ b/templates/deno/src/client.ts.twig @@ -1,6 +1,8 @@ import { {{ spec.title | caseUcfirst}}Exception } from './exception.ts'; +import { Payload } from './payload.ts'; +import { getBoundary, parse as parseMultipart } from './multipart.ts'; -export interface Payload { +export interface Params { [key: string]: any; } @@ -9,7 +11,7 @@ export class Client { static DENO_READ_CHUNK_SIZE = 16384; // 16kb; refference: https://github.com/denoland/deno/discussions/9906 endpoint: string = '{{spec.endpoint}}'; - headers: Payload = { + headers: Params = { 'content-type': '', 'user-agent' : `{{spec.title | caseUcfirst}}{{ language.name | caseUcfirst }}SDK/{{ sdk.version }} (${Deno.build.os}; ${Deno.build.arch})`, 'x-sdk-name': '{{ sdk.name }}', @@ -61,7 +63,7 @@ export class Client { return this; } - async call(method: string, path: string = "", headers: Payload = {}, params: Payload = {}, responseType: string = "json") { + async call(method: string, path: string = "", headers: Params = {}, params: Params = {}, responseType: string = "json") { headers = {...this.headers, ...headers}; const url = new URL(this.endpoint + path); @@ -125,6 +127,40 @@ export class Client { return response.headers.get("location"); } + if (response.headers.get('content-type')?.includes('multipart/form-data')) { + const boundary = getBoundary( + response.headers.get("content-type") || "" + ); + + const body = new Uint8Array(await response.arrayBuffer()); + const parts = parseMultipart(body, boundary); + const partsObject: { [key: string]: any } = {}; + + for (const part of parts) { + if (!part.name) { + continue; + } + if (part.name === "responseBody") { + partsObject[part.name] = Payload.fromBinary(part.data, part.filename); + } else if (part.name === "responseStatusCode") { + partsObject[part.name] = parseInt(part.data.toString()); + } else if (part.name === "duration") { + partsObject[part.name] = parseFloat(part.data.toString()); + } else if (part.type === 'application/json') { + try { + partsObject[part.name] = JSON.parse(part.data.toString()); + } catch (e) { + throw new Error(`Error parsing JSON for part ${part.name}: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + } else { + partsObject[part.name] = new TextDecoder().decode(part.data); + } + } + + const data = partsObject; + return data; + } + const text = await response.text(); let json = undefined; try { @@ -135,8 +171,8 @@ export class Client { return json; } - static flatten(data: Payload, prefix = ''): Payload { - let output: Payload = {}; + static flatten(data: Params, prefix = ''): Params { + let output: Params = {}; for (const [key, value] of Object.entries(data)) { let finalKey = prefix ? prefix + '[' + key +']' : key; diff --git a/templates/deno/src/inputFile.ts.twig b/templates/deno/src/inputFile.ts.twig deleted file mode 100644 index b12230284..000000000 --- a/templates/deno/src/inputFile.ts.twig +++ /dev/null @@ -1,46 +0,0 @@ -const _bufferToString = (buffer: Uint8Array): ReadableStream => { - return new ReadableStream({ - start(controller) { - controller.enqueue(buffer); - controller.close(); - } - }); -}; - -export class InputFile { - stream: ReadableStream; // Content of file as a stream - size: number; // Total final size of the file content - filename: string; // File name - - static fromPath = (filePath: string, filename: string): InputFile => { - const file = Deno.openSync(filePath); - const stream = file.readable; - const size = Deno.statSync(filePath).size; - return new InputFile(stream, filename, size); - }; - - static fromBlob = async (blob: Blob, filename: string) => { - const arrayBuffer = await blob.arrayBuffer(); - const buffer = new Uint8Array(arrayBuffer); - return InputFile.fromBuffer(buffer, filename); - }; - - static fromBuffer = (buffer: Uint8Array, filename: string): InputFile => { - const stream = _bufferToString(buffer); - const size = buffer.byteLength; - return new InputFile(stream, filename, size); - }; - - static fromPlainText = (content: string, filename: string): InputFile => { - const buffer = new TextEncoder().encode(content); - const stream = _bufferToString(buffer); - const size = buffer.byteLength; - return new InputFile(stream, filename, size); - }; - - constructor(stream: ReadableStream, filename: string, size: number) { - this.stream = stream; - this.filename = filename; - this.size = size; - } -} \ No newline at end of file diff --git a/templates/deno/src/models.d.ts.twig b/templates/deno/src/models.d.ts.twig index acbe5834d..4cc7e770d 100644 --- a/templates/deno/src/models.d.ts.twig +++ b/templates/deno/src/models.d.ts.twig @@ -1,3 +1,5 @@ +import { Payload } from './payload.ts'; + {% macro sub_schema(property, definition, spec) %} {% apply spaceless %} {% if property.sub_schema %} diff --git a/templates/deno/src/multipart.ts.twig b/templates/deno/src/multipart.ts.twig new file mode 100644 index 000000000..74cf02dca --- /dev/null +++ b/templates/deno/src/multipart.ts.twig @@ -0,0 +1,211 @@ +/** + * Port of: https://github.com/nachomazzara/parse-multipart-data/blob/master/src/multipart.ts + * Includes few changes for Deno compatibility. Textdiff should show the changes. + * Copied from master with commit 56052e860bc4e3fa7fe4763f69e88ec79b295a3c + * + * + * Multipart Parser (Finite State Machine) + * usage: + * const multipart = require('./multipart.js'); + * const body = multipart.DemoData(); // raw body + * const body = Buffer.from(event['body-json'].toString(),'base64'); // AWS case + * const boundary = multipart.getBoundary(event.params.header['content-type']); + * const parts = multipart.Parse(body,boundary); + * each part is: + * { filename: 'A.txt', type: 'text/plain', data: } + * or { name: 'key', data: } + */ + +type Part = { + contentDispositionHeader: string + contentTypeHeader: string + part: number[] + } + + type Input = { + filename?: string + name?: string + type: string + data: Uint8Array + } + + enum ParsingState { + INIT, + READING_HEADERS, + READING_DATA, + READING_PART_SEPARATOR + } + + export function parse(multipartBodyBuffer: Uint8Array, boundary: string): Input[] { + let lastline = '' + let contentDispositionHeader = '' + let contentTypeHeader = '' + let state: ParsingState = ParsingState.INIT + let buffer: number[] = [] + const allParts: Input[] = [] + + let currentPartHeaders: string[] = [] + + for (let i = 0; i < multipartBodyBuffer.length; i++) { + const oneByte: number = multipartBodyBuffer[i] + const prevByte: number | null = i > 0 ? multipartBodyBuffer[i - 1] : null + // 0x0a => \n + // 0x0d => \r + const newLineDetected: boolean = oneByte === 0x0a && prevByte === 0x0d + const newLineChar: boolean = oneByte === 0x0a || oneByte === 0x0d + + if (!newLineChar) lastline += String.fromCharCode(oneByte) + if (ParsingState.INIT === state && newLineDetected) { + // searching for boundary + if ('--' + boundary === lastline) { + state = ParsingState.READING_HEADERS // found boundary. start reading headers + } + lastline = '' + } else if (ParsingState.READING_HEADERS === state && newLineDetected) { + // parsing headers. Headers are separated by an empty line from the content. Stop reading headers when the line is empty + if (lastline.length) { + currentPartHeaders.push(lastline) + } else { + // found empty line. search for the headers we want and set the values + for (const h of currentPartHeaders) { + if (h.toLowerCase().startsWith('content-disposition:')) { + contentDispositionHeader = h + } else if (h.toLowerCase().startsWith('content-type:')) { + contentTypeHeader = h + } + } + state = ParsingState.READING_DATA + buffer = [] + } + lastline = '' + } else if (ParsingState.READING_DATA === state) { + // parsing data + if (lastline.length > boundary.length + 4) { + lastline = '' // mem save + } + if ('--' + boundary === lastline) { + const j = buffer.length - lastline.length + const part = buffer.slice(0, j - 1) + + allParts.push( + process({ contentDispositionHeader, contentTypeHeader, part }) + ) + buffer = [] + currentPartHeaders = [] + lastline = '' + state = ParsingState.READING_PART_SEPARATOR + contentDispositionHeader = '' + contentTypeHeader = '' + } else { + buffer.push(oneByte) + } + if (newLineDetected) { + lastline = '' + } + } else if (ParsingState.READING_PART_SEPARATOR === state) { + if (newLineDetected) { + state = ParsingState.READING_HEADERS + } + } + } + return allParts + } + + // read the boundary from the content-type header sent by the http client + // this value may be similar to: + // 'multipart/form-data; boundary=----WebKitFormBoundaryvm5A9tzU1ONaGP5B', + export function getBoundary(header: string): string { + const items = header.split(';') + if (items) { + for (let i = 0; i < items.length; i++) { + const item = new String(items[i]).trim() + if (item.indexOf('boundary') >= 0) { + const k = item.split('=') + return new String(k[1]).trim().replace(/^["']|["']$/g, '') + } + } + } + return '' + } + + export function DemoData(): { body: Uint8Array; boundary: string } { + let body = 'trash1\r\n' + body += '------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n' + body += 'Content-Type: text/plain\r\n' + body += + 'Content-Disposition: form-data; name="uploads[]"; filename="A.txt"\r\n' + body += '\r\n' + body += '@11X' + body += '111Y\r\n' + body += '111Z\rCCCC\nCCCC\r\nCCCCC@\r\n\r\n' + body += '------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n' + body += 'Content-Type: text/plain\r\n' + body += + 'Content-Disposition: form-data; name="uploads[]"; filename="B.txt"\r\n' + body += '\r\n' + body += '@22X' + body += '222Y\r\n' + body += '222Z\r222W\n2220\r\n666@\r\n' + body += '------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n' + body += 'Content-Disposition: form-data; name="input1"\r\n' + body += '\r\n' + body += 'value1\r\n' + body += '------WebKitFormBoundaryvef1fLxmoUdYZWXp--\r\n' + + return { + body: new TextEncoder().encode(body), + boundary: '----WebKitFormBoundaryvef1fLxmoUdYZWXp' + } + } + + function process(part: Part): Input { + // will transform this object: + // { header: 'Content-Disposition: form-data; name="uploads[]"; filename="A.txt"', + // info: 'Content-Type: text/plain', + // part: 'AAAABBBB' } + // into this one: + // { filename: 'A.txt', type: 'text/plain', data: } + const obj = function (str: string) { + const k = str.split('=') + const a = k[0].trim() + + const b = JSON.parse(k[1].trim()) + const o = {} + Object.defineProperty(o, a, { + value: b, + writable: true, + enumerable: true, + configurable: true + }) + return o + } + const header = part.contentDispositionHeader.split(';') + + const filenameData = header[2] + let input = {} + if (filenameData) { + input = obj(filenameData) + const contentType = part.contentTypeHeader.split(':')[1].trim() + Object.defineProperty(input, 'type', { + value: contentType, + writable: true, + enumerable: true, + configurable: true + }) + } + // always process the name field + Object.defineProperty(input, 'name', { + value: header[1].split('=')[1].replace(/"/g, ''), + writable: true, + enumerable: true, + configurable: true + }) + + Object.defineProperty(input, 'data', { + value: new Uint8Array(part.part), + writable: true, + enumerable: true, + configurable: true + }) + return input as Input + } \ No newline at end of file diff --git a/templates/deno/src/payload.ts.twig b/templates/deno/src/payload.ts.twig new file mode 100644 index 000000000..2a60443fa --- /dev/null +++ b/templates/deno/src/payload.ts.twig @@ -0,0 +1,60 @@ +import { basename, dirname } from "https://deno.land/std@0.224.0/path/mod.ts"; + +export class Payload { + private data: Uint8Array; + public filename?: string; + public size: number; + + constructor(data: Uint8Array, filename?: string) { + this.data = data; + this.filename = filename; + this.size = data.byteLength; + } + + public toBinary(offset: number = 0, length?: number): Uint8Array { + if (offset === 0 && length === undefined) { + return this.data; + } else if (length === undefined) { + return this.data.subarray(offset); + } else { + return this.data.subarray(offset, offset + length); + } + } + + public toJson(): T { + return JSON.parse(this.toString()); + } + + public toString(): string { + return new TextDecoder().decode(this.data); + } + + public async toFile(path: string): Promise { + await Deno.mkdir(dirname(path), { recursive: true }); + await Deno.writeFile(path, this.data); + } + + public static fromBinary(bytes: Uint8Array, filename?: string): Payload { + return new Payload(bytes, filename); + } + + public static fromJson(object: any, filename?: string): Payload { + const data = new TextEncoder().encode(JSON.stringify(object)); + return new Payload(data, filename); + } + + public static fromString(text: string, filename?: string): Payload { + const data = new TextEncoder().encode(text); + return new Payload(data, filename); + } + + public static async fromFile(path: string, filename?: string): Promise { + const data = await Deno.readFile(path); + + if(!filename) { + filename = basename(path); + } + + return new Payload(data, filename); + } +} \ No newline at end of file diff --git a/templates/deno/src/services/service.ts.twig b/templates/deno/src/services/service.ts.twig index fb1336404..df938d0de 100644 --- a/templates/deno/src/services/service.ts.twig +++ b/templates/deno/src/services/service.ts.twig @@ -25,10 +25,9 @@ {% endfor %} {% endapply %} {% endmacro %} -import { basename } from "https://deno.land/std@0.122.0/path/mod.ts"; import { Service } from '../service.ts'; -import { Payload, Client } from '../client.ts'; -import { InputFile } from '../inputFile.ts'; +import { Params, Client } from '../client.ts'; +import { Payload } from '../payload.ts'; import { AppwriteException } from '../exception.ts'; import type { Models } from '../models.d.ts'; import { Query } from '../query.ts'; @@ -63,59 +62,59 @@ export class {{ service.name | caseUcfirst }} extends Service { { super(client); } + {%~ for method in service.methods %} -{% for method in service.methods %} -{% set generics = _self.get_generics(spec.definitions[method.responseModel], spec, true, true) %} -{% set generics_return = _self.get_generics_return(spec.definitions[method.responseModel], spec) %} + {%~ set generics = _self.get_generics(spec.definitions[method.responseModel], spec, true, true) %} + {%~ set generics_return = _self.get_generics_return(spec.definitions[method.responseModel], spec) %} /** * {{ method.title }} * -{% if method.description %} -{{ method.description|comment1 }} + {%~ if method.description %} + * {{ method.description}} * -{% endif %} -{% for parameter in method.parameters.all%} + {%~ endif %} + {%~ for parameter in method.parameters.all%} * @param {{ '{' }}{{ parameter | typeName }}{{ '}' }} {{ parameter.name | caseCamel | escapeKeyword }} -{% endfor %} + {%~ endfor %} * @throws {AppwriteException} * @returns {Promise} */ async {{ method.name | caseCamel }}{% if generics %}<{{generics}}>{% endif %}({% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | typeName }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress = (progress: UploadProgress) => {}{% endif %}): Promise<{% if method.type == 'webAuth' %}string{% elseif method.type == 'location' %}ArrayBuffer{% else %}{% if method.responseModel and method.responseModel != 'any' %}{% if not spec.definitions[method.responseModel].additionalProperties %}Models.{% endif %}{{method.responseModel | caseUcfirst}}{% if generics_return %}<{{generics_return}}>{% endif %}{% else %}Response{% endif %}{% endif %}> { -{% for parameter in method.parameters.all %} -{% if parameter.required %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.required %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} === 'undefined') { throw new {{spec.title | caseUcfirst}}Exception('Missing required parameter: "{{ parameter.name | caseCamel | escapeKeyword }}"'); } -{% endif %} -{% endfor %} + {%~ endif %} + {%- endfor %} const apiPath = '{{ method.path }}'{% for parameter in method.parameters.path %}.replace('{{ '{' }}{{ parameter.name }}{{ '}' }}', {{ parameter.name | caseCamel | escapeKeyword }}){% endfor %}; - const payload: Payload = {}; + const payload: Params = {}; -{% for parameter in method.parameters.query %} + {%~ for parameter in method.parameters.query %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} !== 'undefined') { payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" and parameter.type != "file" ) %}.toString(){% endif %}; } -{% endfor %} -{% for parameter in method.parameters.body %} + {%~ endfor %} + {%~ for parameter in method.parameters.body %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} !== 'undefined') { payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" and parameter.type != "file" ) %}.toString(){% endif %}; } -{% endfor %} -{% if 'multipart/form-data' in method.consumes %} -{% for parameter in method.parameters.all %} -{% if parameter.type == 'file' %} + {%~ endfor %} + {%~ if 'multipart/form-data' in method.consumes and method.type == "upload" %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.type == 'file' %} const size = {{ parameter.name | caseCamel | escapeKeyword }}.size; const apiHeaders: { [header: string]: string } = { -{% for parameter in method.parameters.header %} + {%~ for parameter in method.parameters.header %} '{{ parameter.name }}': ${{ parameter.name | caseCamel | escapeKeyword }}, -{% endfor %} -{% for key, header in method.headers %} + {%- endfor %} + {%~ for key, header in method.headers %} '{{ key }}': '{{ header }}', -{% endfor %} + {%- endfor %} }; let id: string | undefined = undefined; @@ -123,8 +122,8 @@ export class {{ service.name | caseUcfirst }} extends Service { let chunksUploaded = 0; -{% for parameter in method.parameters.all %} -{% if parameter.isUploadID %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.isUploadID %} if({{ parameter.name | caseCamel | escapeKeyword }} != 'unique()') { try { response = await this.client.call( @@ -136,8 +135,8 @@ export class {{ service.name | caseUcfirst }} extends Service { } catch(e) { } } -{% endif %} -{% endfor %} + {%- endif %} + {%- endfor %} let currentChunk = 1; let currentPosition = 0; @@ -174,7 +173,7 @@ export class {{ service.name | caseUcfirst }} extends Service { apiHeaders['x-{{spec.title | caseLower }}-id'] = id; } - payload['{{ parameter.name }}'] = { type: 'file', file: new File([uploadableChunkTrimmed], {{ parameter.name | caseCamel | escapeKeyword }}.filename), filename: {{ parameter.name | caseCamel | escapeKeyword }}.filename }; + payload['{{ parameter.name }}'] = { type: 'file', file: new File([uploadableChunkTrimmed], {{ parameter.name | caseCamel | escapeKeyword }}.filename ?? ''), filename: {{ parameter.name | caseCamel | escapeKeyword }}.filename ?? '' }; response = await this.client.call('{{ method.method | caseLower }}', apiPath, apiHeaders, payload{% if method.type == 'location' %}, 'arraybuffer'{% elseif method.type == 'webAuth' %}, 'location'{% endif %}); @@ -197,27 +196,26 @@ export class {{ service.name | caseUcfirst }} extends Service { currentChunk++; } - for await (const chunk of {{ parameter.name | caseCamel | escapeKeyword }}.stream) { - let i = 0; - for(const b of chunk) { - uploadableChunk[currentPosition] = chunk[i]; - - if(currentPosition + 1 >= Client.CHUNK_SIZE) { - await uploadChunk(); - currentPosition--; - } + const chunk = {{ parameter.name | caseCamel | escapeKeyword }}.toBinary(); + let i = 0; + for(const _ of chunk) { + uploadableChunk[currentPosition] = chunk[i]; - i++; - currentPosition++; + if(currentPosition + 1 >= Client.CHUNK_SIZE) { + await uploadChunk(); + currentPosition--; } + + i++; + currentPosition++; } await uploadChunk(true); return response; -{% endif %} -{% endfor %} -{% else %} + {%- endif %} + {%- endfor %} + {%~ else %} return await this.client.call( '{{ method.method | caseLower }}', apiPath, @@ -238,7 +236,7 @@ export class {{ service.name | caseUcfirst }} extends Service { 'json' {%~ endif %} ); -{% endif %} + {%~ endif %} } -{% endfor %} + {%~ endfor %} } \ No newline at end of file diff --git a/templates/deno/test/services/service.test.ts.twig b/templates/deno/test/services/service.test.ts.twig index 00743c165..80f04c307 100644 --- a/templates/deno/test/services/service.test.ts.twig +++ b/templates/deno/test/services/service.test.ts.twig @@ -3,7 +3,7 @@ import {restore, stub} from "https://deno.land/std@0.204.0/testing/mock.ts"; import {assertEquals} from "https://deno.land/std@0.204.0/assert/assert_equals.ts"; import { {{ service.name | caseUcfirst }} } from "../../src/services/{{ service.name | caseCamel }}.ts"; import {Client} from "../../src/client.ts"; -import {InputFile} from "../../src/inputFile.ts" +import {Payload} from "../../src/payload.ts" describe('{{ service.name | caseUcfirst }} service', () => { const client = new Client(); @@ -37,7 +37,7 @@ describe('{{ service.name | caseUcfirst }} service', () => { {%~ endif %} const response = await {{ service.name | caseCamel }}.{{ method.name | caseCamel }}({%~ for parameter in method.parameters.all | filter((param) => param.required) ~%} - {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}InputFile.fromBuffer(new Uint8Array(0), 'image.png'){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%},{%~ endfor ~%} + {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}Payload.fromBinary(new Uint8Array(0), 'image.png'){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%},{%~ endfor ~%} ); {%~ if method.type == 'location' %} diff --git a/templates/dotnet/Package/Client.cs.twig b/templates/dotnet/Package/Client.cs.twig index fb6a3f50c..6b2cc024e 100644 --- a/templates/dotnet/Package/Client.cs.twig +++ b/templates/dotnet/Package/Client.cs.twig @@ -10,6 +10,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; +using System.Text.RegularExpressions; using {{ spec.title | caseUcfirst }}.Converters; using {{ spec.title | caseUcfirst }}.Extensions; using {{ spec.title | caseUcfirst }}.Models; @@ -284,6 +285,7 @@ namespace {{ spec.title | caseUcfirst }} } var isJson = contentType.Contains("application/json"); + var isFormData = contentType.Contains("multipart/form-data"); if (code >= 400) { var message = await response.Content.ReadAsStringAsync(); @@ -310,10 +312,12 @@ namespace {{ spec.title | caseUcfirst }} return (dict as T)!; } - else - { - return ((await response.Content.ReadAsByteArrayAsync()) as T)!; - } + + if (!isFormData) return ((await response.Content.ReadAsByteArrayAsync()) as T)!; + + var data = HandleMultipart>(await response.Content.ReadAsByteArrayAsync()); + + return convert != null ? convert(data as Dictionary) : data as T; } public async Task ChunkedUpload( @@ -325,7 +329,7 @@ namespace {{ spec.title | caseUcfirst }} string? idParamName = null, Action? onProgress = null) where T : class { - var input = parameters[paramName] as InputFile; + var input = parameters[paramName] as Payload; var size = 0L; switch(input.SourceType) { @@ -453,5 +457,81 @@ namespace {{ spec.title | caseUcfirst }} return converter(result); } + + public static T HandleMultipart(byte[] multipart) where T : class + { + var str = Encoding.UTF8.GetString(multipart); + var data = new Dictionary(); + var boundarySearch = new Regex(@"(-+\w+)--").Match(str); + + if (boundarySearch.Groups.Count != 2) + { + return new object() as T; + } + + var boundary = boundarySearch.Groups[1].Value; + var parts = str.Split(new string[] { boundary }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var part in parts) + { + var lines = part.Split(new string[]{"\r\n"}, StringSplitOptions.RemoveEmptyEntries); + var nameMatch = new Regex(@"name=""?(\w+)").Match(part); + + if (lines.Length <= 1 || nameMatch.Groups.Count != 2) + { + continue; + } + lines = lines.Skip(1).ToArray(); + var name = nameMatch.Groups[1].Value; + if (lines[0] == "Content-Type: application/json") + { + lines = lines.Skip(1).ToArray(); + data.Add(name, JsonConvert.DeserializeObject>(string.Join("\r\n", lines)) ?? new List()); + continue; + } + if (name == "responseBody") + { + const string needle = "name=\"responseBody\"\r\n\r\n"; + var indexOf = str.IndexOf(needle, StringComparison.Ordinal) + needle.Length; + var endBytes = Encoding.UTF8.GetBytes("\r\n-------"); + multipart = multipart.Skip(indexOf).ToArray(); + + data.Add(name, Payload.FromBinary(multipart.TakeWhile((t, i) => !DidFinishedBinaryData(multipart, endBytes, i)).ToArray())); + + continue; + } + var value = string.Join("\r\n", lines); + + data.Add(name, value); + } + // Adding to match Execution model + data.Add("$id",""); + data.Add("$createdAt",""); + data.Add("$updatedAt",""); + data.Add("logs",""); + data.Add("errors",""); + data.Add("scheduledAt",""); + data.Add("$permissions",new List()); + return data as T; + } + + private static bool DidFinishedBinaryData(byte[] multipart, byte[] endBytes, int i) + { + if (multipart.Length > i + endBytes.Length) + { + for (var j = 0; j < endBytes.Length; j++) + { + if (multipart[i + j] != endBytes[j]) + { + break; + } + + if (j != endBytes.Length - 1) continue; + return true; + } + } + + return false; + } } } diff --git a/templates/dotnet/Package/Extensions/Extensions.cs.twig b/templates/dotnet/Package/Extensions/Extensions.cs.twig index 10b2b5035..3fba2cac3 100644 --- a/templates/dotnet/Package/Extensions/Extensions.cs.twig +++ b/templates/dotnet/Package/Extensions/Extensions.cs.twig @@ -603,25 +603,5 @@ namespace {{ spec.title | caseUcfirst }}.Extensions #endregion }; - - public static string GetMimeTypeFromExtension(string extension) - { - if (extension == null) - { - throw new ArgumentNullException("extension"); - } - - if (!extension.StartsWith(".")) - { - extension = "." + extension; - } - - return _mappings.TryGetValue(extension, out var mime) ? mime : "application/octet-stream"; - } - - public static string GetMimeType(this string path) - { - return GetMimeTypeFromExtension(System.IO.Path.GetExtension(path)); - } } } \ No newline at end of file diff --git a/templates/dotnet/Package/Models/InputFile.cs.twig b/templates/dotnet/Package/Models/InputFile.cs.twig deleted file mode 100644 index 5d98b2167..000000000 --- a/templates/dotnet/Package/Models/InputFile.cs.twig +++ /dev/null @@ -1,41 +0,0 @@ -using System.IO; -using Appwrite.Extensions; - -namespace {{ spec.title | caseUcfirst }}.Models -{ - public class InputFile - { - public string Path { get; set; } - public string Filename { get; set; } - public string MimeType { get; set; } - public string SourceType { get; set; } - public object Data { get; set; } - - public static InputFile FromPath(string path) => new InputFile - { - Path = path, - Filename = System.IO.Path.GetFileName(path), - MimeType = path.GetMimeType(), - SourceType = "path" - }; - - public static InputFile FromFileInfo(FileInfo fileInfo) => - InputFile.FromPath(fileInfo.FullName); - - public static InputFile FromStream(Stream stream, string filename, string mimeType) => new InputFile - { - Data = stream, - Filename = filename, - MimeType = mimeType, - SourceType = "stream" - }; - - public static InputFile FromBytes(byte[] bytes, string filename, string mimeType) => new InputFile - { - Data = bytes, - Filename = filename, - MimeType = mimeType, - SourceType = "bytes" - }; - } -} \ No newline at end of file diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index 00df5c7a9..80a033a7a 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -40,7 +40,7 @@ namespace {{ spec.title | caseUcfirst }}.Models public static {{ definition.name | caseUcfirst | overrideIdentifier}} From(Dictionary map) => new {{ definition.name | caseUcfirst | overrideIdentifier}}( {%~ for property in definition.properties %} - {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}: {% if property.sub_schema %}{% if property.type == 'array' %}((JArray)map["{{ property.name }}"]).ToObject>>().Select(it => {{property.sub_schema | caseUcfirst | overrideIdentifier}}.From(map: it)).ToList(){% else %}{{property.sub_schema | caseUcfirst | overrideIdentifier}}.From(map: ((JObject)map["{{ property.name }}"]).ToObject>()!){% endif %}{% else %}{% if property.type == 'array' %}((JArray)map["{{ property.name }}"]).ToObject<{{ property | typeName }}>(){% else %}{% if property.type == "integer" or property.type == "number" %}{% if not property.required %}map["{{ property.name }}"] == null ? null : {% endif %}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}(map["{{ property.name }}"]){% else %}{% if property.type == "boolean" %}({{ property | typeName }}{% if not property.required %}?{% endif %})map["{{ property.name }}"]{% else %}map{% if not property.required %}.TryGetValue("{{ property.name }}", out var {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}) ? {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}?.ToString() : null{% else %}["{{ property.name }}"]{% if not property.required %}?{% endif %}.ToString(){% endif %}{% endif %}{% endif %}{% endif %}{% endif %}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} + {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}: {% if property.sub_schema %}{% if property.type == 'array' %}((JArray)map["{{ property.name }}"]).ToObject>>().Select(it => {{property.sub_schema | caseUcfirst | overrideIdentifier}}.From(map: it)).ToList(){% else %}{{property.sub_schema | caseUcfirst | overrideIdentifier}}.From(map: ((JObject)map["{{ property.name }}"]).ToObject>()!){% endif %}{% else %}{% if property.type == 'array' %}((JArray)map["{{ property.name }}"]).ToObject<{{ property | typeName }}>(){% else %}{% if property.type == "integer" or property.type == "number" %}{% if not property.required %}map["{{ property.name }}"] == null ? null : {% endif %}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}(map["{{ property.name }}"]){% else %}{% if property.type == "boolean" %}({{ property | typeName }}{% if not property.required %}?{% endif %})map["{{ property.name }}"]{% else %}map{% if not property.required %}.TryGetValue("{{ property.name }}", out var {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}) ? {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}{% if property.name == "responseBody" or property.type | caseLower == "file" %} as Payload{% else %}?.ToString(){% endif %} : null{% else %}["{{ property.name }}"]{% if not property.required %}?{% endif %}{% if property.name == "responseBody" or property.type | caseLower == "payload" %} as Payload{% else %}.ToString(){% endif %}{% endif %}{% endif %}{% endif %}{% endif %}{% endif %}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} {%~ endfor %} {%~ if definition.additionalProperties %} @@ -76,4 +76,4 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} {%~ endfor %} } -} \ No newline at end of file +} diff --git a/templates/dotnet/Package/Models/Payload.cs.twig b/templates/dotnet/Package/Models/Payload.cs.twig new file mode 100644 index 000000000..7c2e6c4db --- /dev/null +++ b/templates/dotnet/Package/Models/Payload.cs.twig @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; + +using {{ spec.title | caseUcfirst }}.Extensions; + +namespace {{ spec.title | caseUcfirst }}.Models +{ + public class Payload + { + public string Path { get; set; } + public string Filename { get; set; } + public string SourceType { get; set; } + public object Data { get; set; } + + public static Payload FromFile(string path, string filename = null) => new Payload + { + Path = path, + Filename = filename == null ? System.IO.Path.GetFileName(path) : filename, + SourceType = "path" + }; + + public static Payload FromFileInfo(FileInfo fileInfo, string filename = null) => + FromFile(fileInfo.FullName, filename); + + public static Payload FromStream(Stream stream, string filename = null) => new Payload + { + Data = stream, + Filename = filename, + SourceType = "stream" + }; + + public static Payload FromBinary(byte[] bytes, string filename = null) => new Payload + { + Data = bytes, + Filename = filename, + SourceType = "bytes" + }; + + public static Payload FromString(string multipart, string filename = null) + { + return FromBinary(System.Text.Encoding.UTF8.GetBytes(multipart), filename); + } + + public static Payload FromJson(object json, string filename = null) + { + return FromString(JsonConvert.SerializeObject(json), filename); + } + + public byte[] ToBinary() + { + return Data as byte[] ?? Array.Empty(); + } + + public override string ToString() + { + var binary = ToBinary(); + return System.Text.Encoding.UTF8.GetString(binary, 0, binary.Length); + } + + public Dictionary ToJson() + { + return JsonConvert.DeserializeObject>(ToString()) ?? new Dictionary(); + } + + public void ToFile(string path) + { + System.IO.File.WriteAllBytes(path, ToBinary()); + } + + } +} diff --git a/templates/dotnet/Package/Services/ServiceTemplate.cs.twig b/templates/dotnet/Package/Services/ServiceTemplate.cs.twig index c20801c06..ca3a3b6cd 100644 --- a/templates/dotnet/Package/Services/ServiceTemplate.cs.twig +++ b/templates/dotnet/Package/Services/ServiceTemplate.cs.twig @@ -37,6 +37,28 @@ namespace {{ spec.title | caseUcfirst }}.Services {{~ include('dotnet/base/params.twig') }} {%~ if method.responseModel %} + {%~ if 'multipart/form-data' in method.consumes and method.type != "upload" %} + static Models.Execution Convert(Dictionary it) => + new Execution( + id: it["$id"].ToString(), + createdAt: it["$createdAt"].ToString(), + updatedAt: it["$updatedAt"].ToString(), + permissions: it["$permissions"] as List, + functionId: it["functionId"].ToString(), + trigger: it["trigger"].ToString(), + status: it["status"].ToString(), + requestMethod: it["requestMethod"].ToString(), + requestPath: it["requestPath"].ToString(), + requestHeaders: it["requestHeaders"] as List, + responseStatusCode: System.Convert.ToInt64(it["responseStatusCode"]), + responseBody: it["responseBody"] as Payload, + responseHeaders: it["responseHeaders"] as List, + logs: it["logs"].ToString(), + errors: it["errors"].ToString(), + duration: System.Convert.ToDouble(it["duration"]), + scheduledAt: it.TryGetValue("scheduledAt", out var scheduledAt) ? scheduledAt.ToString() : null + ); + {%~ else %} static {{ utils.resultType(spec.title, method) }} Convert(Dictionary it) => {%~ if method.responseModel == 'any' %} it; @@ -44,12 +66,13 @@ namespace {{ spec.title | caseUcfirst }}.Services {{ utils.resultType(spec.title, method) }}.From(map: it); {%~ endif %} {%~ endif %} + {%~ endif %} {%~ if method.type == 'location' %} {{~ include('dotnet/base/requests/location.twig') }} {%~ elseif method.type == 'webAuth' %} {{~ include('dotnet/base/requests/oauth.twig') }} - {%~ elseif 'multipart/form-data' in method.consumes %} + {%~ elseif 'multipart/form-data' in method.consumes and method.type == "upload" %} {{~ include('dotnet/base/requests/file.twig') }} {%~ else %} {{~ include('dotnet/base/requests/api.twig')}} diff --git a/templates/dotnet/base/params.twig b/templates/dotnet/base/params.twig index 482ae36ed..77baedda5 100644 --- a/templates/dotnet/base/params.twig +++ b/templates/dotnet/base/params.twig @@ -15,7 +15,9 @@ var apiHeaders = new Dictionary() { {%~ for key, header in method.headers %} + {%~ if 'multipart/form-data' in method.consumes and method.type != "upload" %} + {"accept", "multipart/form-data"}, + {%~ endif %} { "{{ key }}", "{{ header }}" }{% if not loop.last %},{% endif %} - {%~ endfor %} }; diff --git a/templates/flutter/lib/client_browser.dart.twig b/templates/flutter/lib/client_browser.dart.twig deleted file mode 100644 index 09f110ea7..000000000 --- a/templates/flutter/lib/client_browser.dart.twig +++ /dev/null @@ -1 +0,0 @@ -export 'src/client_browser.dart'; \ No newline at end of file diff --git a/templates/flutter/lib/client_io.dart.twig b/templates/flutter/lib/client_io.dart.twig deleted file mode 100644 index 4d85cbfa6..000000000 --- a/templates/flutter/lib/client_io.dart.twig +++ /dev/null @@ -1 +0,0 @@ -export 'src/client_io.dart'; \ No newline at end of file diff --git a/templates/flutter/lib/package.dart.twig b/templates/flutter/lib/package.dart.twig index afb2ffca3..51b2adaab 100644 --- a/templates/flutter/lib/package.dart.twig +++ b/templates/flutter/lib/package.dart.twig @@ -12,9 +12,9 @@ import 'dart:convert'; import 'src/enums.dart'; import 'src/service.dart'; -import 'src/input_file.dart'; import 'models.dart' as models; import 'enums.dart' as enums; +import 'payload.dart'; import 'src/upload_progress.dart'; export 'src/response.dart'; @@ -24,7 +24,7 @@ export 'src/realtime.dart'; export 'src/upload_progress.dart'; export 'src/realtime_subscription.dart'; export 'src/realtime_message.dart'; -export 'src/input_file.dart'; +export 'payload.dart'; part 'query.dart'; part 'permission.dart'; diff --git a/templates/flutter/lib/services/service.dart.twig b/templates/flutter/lib/services/service.dart.twig index 206045105..becc0020a 100644 --- a/templates/flutter/lib/services/service.dart.twig +++ b/templates/flutter/lib/services/service.dart.twig @@ -23,7 +23,7 @@ class {{ service.name | caseUcfirst }} extends Service { {% if method.type == 'webAuth' %}Future{% elseif method.type == 'location' %}Future{% else %}{% if method.responseModel and method.responseModel != 'any' %}Future{% else %}Future{% endif %}{% endif %} {{ method.name | caseCamel | overrideIdentifier }}({{ _self.method_parameters(method.parameters.all, method.consumes) }}) async { {% if method.parameters.path | length > 0 %}final{% else %}const{% endif %} String apiPath = '{{ method.path }}'{% for parameter in method.parameters.path %}.replaceAll('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', {{ parameter.name | caseCamel | overrideIdentifier }}{% if parameter.enumValues | length > 0 %}.value{% endif %}){% endfor %}; -{% if 'multipart/form-data' in method.consumes %} +{%~ if 'multipart/form-data' in method.consumes and method.type == "upload" %} {{ include('flutter/base/requests/file.twig') }} {% elseif method.type == 'webAuth' %} {{ include('flutter/base/requests/oauth.twig') }} diff --git a/templates/flutter/lib/src/client_browser.dart.twig b/templates/flutter/lib/src/client_browser.dart.twig index e3bffef21..12d744111 100644 --- a/templates/flutter/lib/src/client_browser.dart.twig +++ b/templates/flutter/lib/src/client_browser.dart.twig @@ -8,7 +8,7 @@ import 'client_mixin.dart'; import 'enums.dart'; import 'exception.dart'; import 'client_base.dart'; -import 'input_file.dart'; +import '../payload.dart'; import 'upload_progress.dart'; import 'response.dart'; @@ -116,15 +116,15 @@ class ClientBrowser extends ClientBase with ClientMixin { Function(UploadProgress)? onProgress, }) async { InputFile file = params[paramName]; - if (file.bytes == null) { - throw {{spec.title | caseUcfirst}}Exception("File bytes must be provided for Flutter web"); + if (file.data == null) { + throw {{spec.title | caseUcfirst}}Exception("File data must be provided for Flutter web"); } - int size = file.bytes!.length; + int size = file.data!.length; late Response res; if (size <= CHUNK_SIZE) { - params[paramName] = http.MultipartFile.fromBytes(paramName, file.bytes!, filename: file.filename); + params[paramName] = http.MultipartFile.fromBytes(paramName, file.data!, filename: file.filename); return call( HttpMethod.post, path: path, @@ -150,7 +150,7 @@ class ClientBrowser extends ClientBase with ClientMixin { while (offset < size) { List chunk = []; final end = min(offset + CHUNK_SIZE, size); - chunk = file.bytes!.getRange(offset, end).toList(); + chunk = file.toBinary(offset: offset, length: min(CHUNK_SIZE, size - offset)).toList(); params[paramName] = http.MultipartFile.fromBytes(paramName, chunk, filename: file.filename); headers['content-range'] = diff --git a/templates/flutter/lib/src/client_io.dart.twig b/templates/flutter/lib/src/client_io.dart.twig index 371dd51ee..1e08445ed 100644 --- a/templates/flutter/lib/src/client_io.dart.twig +++ b/templates/flutter/lib/src/client_io.dart.twig @@ -7,6 +7,8 @@ import 'package:http/io_client.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:flutter/foundation.dart'; + import 'client_mixin.dart'; import 'client_base.dart'; import 'cookie_manager.dart'; @@ -14,8 +16,7 @@ import 'enums.dart'; import 'exception.dart'; import 'interceptor.dart'; import 'response.dart'; -import 'package:flutter/foundation.dart'; -import 'input_file.dart'; +import '../payload.dart'; import 'upload_progress.dart'; ClientBase createClient({ @@ -218,14 +219,14 @@ class ClientIO extends ClientBase with ClientMixin { required Map headers, Function(UploadProgress)? onProgress, }) async { - InputFile file = params[paramName]; - if (file.path == null && file.bytes == null) { - throw {{spec.title | caseUcfirst}}Exception("File path or bytes must be provided"); + Payload file = params[paramName]; + if (file.path == null && file.data == null) { + throw {{spec.title | caseUcfirst}}Exception("File path or data must be provided"); } int size = 0; - if (file.bytes != null) { - size = file.bytes!.length; + if (file.data != null) { + size = file.data!.length; } File? iofile; @@ -242,7 +243,7 @@ class ClientIO extends ClientBase with ClientMixin { paramName, file.path!, filename: file.filename); } else { - params[paramName] = http.MultipartFile.fromBytes(paramName, file.bytes!, + params[paramName] = http.MultipartFile.fromBytes(paramName, file.data!, filename: file.filename); } return call( @@ -275,9 +276,8 @@ class ClientIO extends ClientBase with ClientMixin { while (offset < size) { List chunk = []; - if (file.bytes != null) { - final end = min(offset + CHUNK_SIZE, size); - chunk = file.bytes!.getRange(offset, end).toList(); + if (file.data != null) { + chunk = file.toBinary(offset: offset, length: min(CHUNK_SIZE, size - offset)); } else { raf!.setPositionSync(offset); chunk = raf.readSync(CHUNK_SIZE); diff --git a/templates/flutter/lib/src/client_mixin.dart.twig b/templates/flutter/lib/src/client_mixin.dart.twig deleted file mode 100644 index dacaa01b4..000000000 --- a/templates/flutter/lib/src/client_mixin.dart.twig +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:http/http.dart' as http; -import 'exception.dart'; -import 'response.dart'; -import 'dart:convert'; -import 'dart:developer'; -import 'enums.dart'; - -class ClientMixin { - http.BaseRequest prepareRequest( - HttpMethod method, { - required Uri uri, - required Map headers, - required Map params, - }) { - if (params.isNotEmpty) { - params.removeWhere((key, value) => value == null); - } - - http.BaseRequest request = http.Request(method.name(), uri); - if (headers['content-type'] == 'multipart/form-data') { - request = http.MultipartRequest(method.name(), uri); - if (params.isNotEmpty) { - params.forEach((key, value) { - if (value is http.MultipartFile) { - (request as http.MultipartRequest).files.add(value); - } else { - if (value is List) { - value.asMap().forEach((i, v) { - (request as http.MultipartRequest) - .fields - .addAll({"$key[$i]": v.toString()}); - }); - } else { - (request as http.MultipartRequest) - .fields - .addAll({key: value.toString()}); - } - } - }); - } - } else if (method == HttpMethod.get) { - if (params.isNotEmpty) { - params = params.map((key, value){ - if (value is int || value is double) { - return MapEntry(key, value.toString()); - } - if (value is List) { - return MapEntry(key + "[]", value); - } - return MapEntry(key, value); - }); - } - uri = Uri( - fragment: uri.fragment, - path: uri.path, - host: uri.host, - scheme: uri.scheme, - queryParameters: params, - port: uri.port); - request = http.Request(method.name(), uri); - } else { - (request as http.Request).body = jsonEncode(params); - } - - request.headers.addAll(headers); - return request; - } - - Response prepareResponse(http.Response res, {ResponseType? responseType}) { - responseType ??= ResponseType.json; - - String? warnings = res.headers['x-{{ spec.title | lower }}-warning']; - if (warnings != null) { - warnings.split(';').forEach((warning) => log('Warning: $warning')); - } - - if (res.statusCode >= 400) { - if ((res.headers['content-type'] ?? '').contains('application/json')) { - final response = json.decode(res.body); - throw {{spec.title | caseUcfirst}}Exception( - response['message'], - response['code'], - response['type'], - response, - ); - } else { - throw {{spec.title | caseUcfirst}}Exception(res.body); - } - } - dynamic data; - if ((res.headers['content-type'] ?? '').contains('application/json')) { - if (responseType == ResponseType.json) { - data = json.decode(res.body); - } else if (responseType == ResponseType.bytes) { - data = res.bodyBytes; - } else { - data = res.body; - } - } else { - if (responseType == ResponseType.bytes) { - data = res.bodyBytes; - } else { - data = res.body; - } - } - return Response(data: data); - } - - Future toResponse(http.StreamedResponse streamedResponse) async { - if(streamedResponse.statusCode == 204) { - return http.Response('', - streamedResponse.statusCode, - headers: streamedResponse.headers.map((k,v) => k.toLowerCase()=='content-type' ? MapEntry(k, 'text/plain') : MapEntry(k,v)), - request: streamedResponse.request, - isRedirect: streamedResponse.isRedirect, - persistentConnection: streamedResponse.persistentConnection, - reasonPhrase: streamedResponse.reasonPhrase, - ); - } else { - return await http.Response.fromStream(streamedResponse); - } - } -} diff --git a/templates/flutter/lib/src/client_stub.dart.twig b/templates/flutter/lib/src/client_stub.dart.twig deleted file mode 100644 index 40423b492..000000000 --- a/templates/flutter/lib/src/client_stub.dart.twig +++ /dev/null @@ -1,6 +0,0 @@ -import 'client_base.dart'; - -/// Implemented in `client_browser.dart` and `client_io.dart`. -ClientBase createClient({required String endPoint, required bool selfSigned}) => - throw UnsupportedError( - 'Cannot create a client without dart:html or dart:io.'); diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index bfc5d0265..774d0606c 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -22,7 +22,6 @@ mixin RealtimeMixin { GetFallbackCookie? getFallbackCookie; int? get closeCode => _websok?.closeCode; Map _subscriptions = {}; - bool _notifyDone = true; bool _reconnect = true; int _retries = 0; StreamSubscription? _websocketSubscription; @@ -49,11 +48,9 @@ mixin RealtimeMixin { _creatingSocket = false; return; } - _notifyDone = false; await _closeConnection(); _lastUrl = uri.toString(); _websok = await getWebSocket(uri); - _notifyDone = true; } debugPrint('subscription: $_lastUrl'); _retries = 0; diff --git a/templates/flutter/pubspec.yaml.twig b/templates/flutter/pubspec.yaml.twig index 911cc58ec..d61d74047 100644 --- a/templates/flutter/pubspec.yaml.twig +++ b/templates/flutter/pubspec.yaml.twig @@ -13,7 +13,7 @@ platforms: web: windows: environment: - sdk: '>=2.17.0 <4.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: flutter: @@ -26,6 +26,9 @@ dependencies: path_provider: ^2.1.4 web_socket_channel: ^3.0.1 web: ^1.0.0 + http_parser: ^4.0.2 + mime: ^1.0.6 + string_scanner: ^1.2.0 dev_dependencies: path_provider_platform_interface: ^2.1.2 @@ -33,3 +36,4 @@ dev_dependencies: flutter_test: sdk: flutter mockito: ^5.4.4 + crypto: ^3.0.1 diff --git a/templates/go/README.md.twig b/templates/go/README.md.twig index e28ea3a95..aa7396140 100644 --- a/templates/go/README.md.twig +++ b/templates/go/README.md.twig @@ -36,10 +36,10 @@ go get github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }} * Then inject these environment variables: ```bash - export YOUR_ENDPOINT=https://{{ sdk.gitUserName|url_encode }}.io/v1 - export YOUR_PROJECT_ID=6…8 - export YOUR_KEY="7055781…cd95" - export COLLECTION_ID=616a095b20180 + export YOUR_ENDPOINT=https://{{ sdk.gitUserName|url_encode }}.io/v1 + export YOUR_PROJECT_ID=6…8 + export YOUR_KEY="7055781…cd95" + export COLLECTION_ID=616a095b20180 ``` Create `main.go` file with: @@ -63,7 +63,7 @@ func main() { appwrite.WithKey(os.Getenv("YOUR_KEY")), ) - databases := appwrite.NewDatabase(client) + databases := appwrite.NewDatabases(client) data := map[string]string{ "hello": "world", @@ -84,8 +84,8 @@ func main() { * After that, run the following - > % go run main.go - > 2021/10/16 03:41:17 Created document: map[$collection:616a095b20180 $id:616a2dbd4df16 $permissions:map[read:[] write:[]] hello:world] + > % go run main.go + > 2021/10/16 03:41:17 Created document: map[$collection:616a095b20180 $id:616a2dbd4df16 $permissions:map[read:[] write:[]] hello:world] {% if sdk.gettingStarted %} diff --git a/templates/go/base/params.twig b/templates/go/base/params.twig index 8e7933776..a4fe7cc5c 100644 --- a/templates/go/base/params.twig +++ b/templates/go/base/params.twig @@ -10,7 +10,11 @@ params["{{ parameter.name }}"] = {{ parameter.name | caseUcfirst }} {% else %} if options.enabledSetters["{{ parameter.name | caseUcfirst}}"] { + {%~ if parameter.type == "payload" %} + params["{{ parameter.name }}"] = string(options.{{ parameter.name | caseUcfirst }}.Data) + {%~ else %} params["{{ parameter.name }}"] = options.{{ parameter.name | caseUcfirst }} + {%~ endif %} } {% endif %} {% endfor %} diff --git a/templates/go/base/requests/api.twig b/templates/go/base/requests/api.twig index 863b2658f..7f7021136 100644 --- a/templates/go/base/requests/api.twig +++ b/templates/go/base/requests/api.twig @@ -3,12 +3,12 @@ return nil, err } if strings.HasPrefix(resp.Type, "application/json") { - bytes := []byte(resp.Result.(string)) + bytesData := []byte(resp.Result.(string)) {%~ if method | returnType(spec, spec.title | caseLower) != 'interface{}' and method | returnType(spec, spec.title | caseLower) != '[]byte' and method | returnType(spec, spec.title | caseLower) != 'bool' %} - parsed := {{ method | returnType(spec, spec.title | caseLower) }}{}.New(bytes) + parsed := {{ method | returnType(spec, spec.title | caseLower) }}{}.New(bytesData) - err = json.Unmarshal(bytes, parsed) + err = json.Unmarshal(bytesData, parsed) if err != nil { return nil, err } @@ -17,13 +17,16 @@ {%~ else %} var parsed {{ method | returnType(spec, spec.title | caseLower) }} - err = json.Unmarshal(bytes, &parsed) + err = json.Unmarshal(bytesData, &parsed) if err != nil { return nil, err } return &parsed, nil {%~ endif %} } +{% if 'multipart/form-data' in method.consumes and method.type != "upload" %} +{{ include('go/base/requests/execution.twig') }} +{%~ endif %} var parsed {{ method | returnType(spec, spec.title | caseLower) }} parsed, ok := resp.Result.({{ method | returnType(spec, spec.title | caseLower) }}) if !ok { diff --git a/templates/go/base/requests/execution.twig b/templates/go/base/requests/execution.twig new file mode 100644 index 000000000..a21e1044d --- /dev/null +++ b/templates/go/base/requests/execution.twig @@ -0,0 +1,92 @@ + if strings.Contains(resp.Type, "multipart/form-data") { + bytesData, ok := resp.Result.([]byte) + + if !ok { + return nil, errors.New("unexpected response type") + } + responseData := string(bytesData) + + matches := regexp.MustCompile("(-+\\w+)--").FindStringSubmatch(responseData) + + if len(matches) != 2 { + return nil, errors.New("unexpected response type") + } + + parts := strings.Split(responseData, matches[1]) + + if len(parts) == 0 { + return nil, errors.New("unexpected response type") + } + execution := make(map[string]string, 10) + + for _, part := range parts { + cleanPart := strings.TrimSpace(part) + partName := regexp.MustCompile("name=\"?(\\w+)").FindStringSubmatch(cleanPart) + + if len(partName) != 2 { + continue + } + + name := strings.TrimSpace(partName[1]) + lines := strings.Split(strings.ReplaceAll(cleanPart, "\r\n", "\n"), "\n") + + Inner: + for i, line := range lines[1:] { + if line == "" { + continue + } + + if line == "Content-Type: application/json" { + for _, line := range lines[i:] { + if line == "" { + continue + } + + execution[name] = line + } + continue Inner + } + execution[name] += line + "\r\n" + } + execution[name] = strings.TrimSuffix(execution[name],"\r\n") + } + + statusCode, err := strconv.Atoi(execution["responseStatusCode"]) + if err != nil { + statusCode = 0 + } + + duration, err := strconv.ParseFloat(execution["duration"], 64) + if err != nil { + duration = 0.0 + } + + var requestHeaders []models.Headers + var responseHeaders []models.Headers + + buffer := bytes.NewBuffer([]byte(execution["requestHeaders"])) + decoder := json.NewDecoder(buffer) + _ = decoder.Decode(&requestHeaders) + + buffer = bytes.NewBuffer([]byte(execution["responseHeaders"])) + decoder = json.NewDecoder(buffer) + _ = decoder.Decode(&responseHeaders) + + results := models.Execution{ + FunctionId: execution["functionId"], + Trigger: execution["trigger"], + Status: execution["status"], + RequestMethod: execution["requestMethod"], + RequestPath: execution["requestPath"], + RequestHeaders: requestHeaders, + ResponseStatusCode: statusCode, + ResponseBody: payload.NewPayloadFromString(execution["responseBody"]), + ResponseHeaders: responseHeaders, + Logs: execution["logs"], + Errors: execution["errors"], + Duration: duration, + ScheduledAt: execution["scheduledAt"], + } + + return &results, nil + } diff --git a/templates/go/client.go.twig b/templates/go/client.go.twig index 2316630a7..ab052e497 100644 --- a/templates/go/client.go.twig +++ b/templates/go/client.go.twig @@ -19,7 +19,7 @@ import ( "time" "runtime" - "github.com/{{sdk.gitUserName}}/sdk-for-go/file" + "github.com/{{sdk.gitUserName}}/sdk-for-go/payload" ) const ( @@ -122,7 +122,7 @@ func (client *Client) AddHeader(key string, value string) { client.Headers[key] = value } -func isFileUpload(headers map[string]interface{}) bool { +func isMultipart(headers map[string]interface{}) bool { contentType, ok := headers["content-type"].(string) if ok { return strings.Contains(strings.ToLower(contentType), "multipart/form-data") @@ -131,13 +131,13 @@ func isFileUpload(headers map[string]interface{}) bool { } func (client *Client) FileUpload(url string, headers map[string]interface{}, params map[string]interface{}, paramName string, uploadId string) (*ClientResponse, error) { - inputFile, ok := params[paramName].(file.InputFile) + payload, ok := params[paramName].(*payload.Payload) if !ok { - msg := fmt.Sprintf("invalid input file. params[%s] must be of type file.InputFile", paramName) + msg := fmt.Sprintf("invalid input file. params[%s] must be of type payload.Payload", paramName) return nil, errors.New(msg) } - file, err := os.Open(inputFile.Path) + file, err := os.Open(payload.Path) if err != nil { return nil, err } @@ -148,7 +148,7 @@ func (client *Client) FileUpload(url string, headers map[string]interface{}, par return nil, err } - inputFile.Data = make([]byte, client.ChunkSize) + payload.Data = make([]byte, client.ChunkSize) var result *ClientResponse @@ -168,12 +168,12 @@ func (client *Client) FileUpload(url string, headers map[string]interface{}, par if uploadId != "" && uploadId != "unique()" { headers["x-appwrite-id"] = uploadId } - inputFile.Data = make([]byte, fileInfo.Size()) - _, err := file.Read(inputFile.Data) + payload.Data = make([]byte, fileInfo.Size()) + _, err := file.Read(payload.Data) if err != nil && err != io.EOF { return nil, err } - params[paramName] = inputFile + params[paramName] = payload result, err = client.Call("POST", url, headers, params) if err != nil { @@ -196,13 +196,13 @@ func (client *Client) FileUpload(url string, headers map[string]interface{}, par offset := int64(i) * chunkSize if i == numChunks-1 { chunkSize = fileInfo.Size() - offset - inputFile.Data = make([]byte, chunkSize) + payload.Data = make([]byte, chunkSize) } - _, err := file.ReadAt(inputFile.Data, offset) + _, err := file.ReadAt(payload.Data, offset) if err != nil && err != io.EOF { return nil, err } - params[paramName] = inputFile + params[paramName] = payload if uploadId != "" && uploadId != "unique()" { headers["x-appwrite-id"] = uploadId } @@ -248,18 +248,19 @@ func (client *Client) Call(method string, path string, headers map[string]interf isGet := strings.ToUpper(method) == "GET" isPost := strings.ToUpper(method) == "POST" isJsonRequest := headers["content-type"] == "application/json" - isFileUpload := isFileUpload(headers) + isMultipart := isMultipart(headers) var req *http.Request var err error - if isFileUpload { + if isMultipart { + headers["accept"] = "multipart/form-data" if !isPost { return nil, errors.New("fileupload needs POST Request") } var body bytes.Buffer writer := multipart.NewWriter(&body) for key, val := range params { - if file, ok := val.(file.InputFile); ok { + if file, ok := val.(*payload.Payload); ok { fileName := file.Name fileData := file.Data fw, err := writer.CreateFormFile(key, fileName) diff --git a/templates/go/docs/example.md.twig b/templates/go/docs/example.md.twig index cbd611f73..a7cc88837 100644 --- a/templates/go/docs/example.md.twig +++ b/templates/go/docs/example.md.twig @@ -8,27 +8,26 @@ package main import ( "fmt" - "github.com/{{ sdk.gitUserName|url_encode }}/{{ sdk.gitRepoName|url_encode }}/client" - "github.com/{{ sdk.gitUserName|url_encode }}/{{ sdk.gitRepoName|url_encode }}/{{ service.name | caseLower }}" -{% if requireFilesPkg %} - "github.com/{{sdk.gitUserName}}/sdk-for-go/file" + "github.com/{{sdk.gitUserName}}/sdk-for-go/appwrite" +{% if requireFilesPkg or method.name | caseLower == "createexecution" %} + "github.com/{{sdk.gitUserName}}/sdk-for-go/payload" {% endif %} ) func main() { - client := client.NewClient() - + client := appwrite.NewClient( {% if method.auth|length > 0 %} - client.SetEndpoint("https://cloud.appwrite.io/v1") // Your API Endpoint + appwrite.WithEndpoint("https://cloud.appwrite.io/v1"), // Your API Endpoint {% for node in method.auth %} {% for key,header in node|keys %} - client.Set{{header}}("{{node[header]['x-appwrite']['demo'] | raw }}") // {{node[header].description}} + appwrite.With{{header}}("{{node[header]['x-{{ spec.title | caseLower }}']['demo']}}"), // {{node[header].description}} {% endfor %} {% endfor %} + ) {% endif %} - service := {{ service.name | caseLower }}.New{{ service.name | caseUcfirst }}(client) - response, error := service.{{ method.name | caseUcfirst }}( + {{service.name}} := appwrite.New{{ service.name | caseUcfirst }}(client) + response, error := {{service.name}}.{{ method.name | caseUcfirst }}( {% for parameter in method.parameters.all %} {% if parameter.required %} {{ parameter | paramExample }}, diff --git a/templates/go/inputFile.go.twig b/templates/go/inputFile.go.twig deleted file mode 100644 index 73d2c446e..000000000 --- a/templates/go/inputFile.go.twig +++ /dev/null @@ -1,15 +0,0 @@ -package file - -type InputFile struct { - Name string - Path string - Data []byte -} - -func NewInputFile(path string, name string) InputFile { - return InputFile{ - Name: name, - Path: path, - Data: nil, - } -} diff --git a/templates/go/models/model.go.twig b/templates/go/models/model.go.twig index 2db5bf15b..38ed60697 100644 --- a/templates/go/models/model.go.twig +++ b/templates/go/models/model.go.twig @@ -3,6 +3,9 @@ package models import ( "encoding/json" "errors" +{%~ if definition.name | caseLower == 'execution' or definition.name | caseLower == 'multipart' or definition.name | caseLower == 'multipartecho' %} + "github.com/{{sdk.gitUserName}}/sdk-for-go/payload" +{%~ endif %} ) {{ ((definition.description | caseUcfirst) ~ " Model") | godocComment }} @@ -35,4 +38,4 @@ func (model *{{ definition.name | caseUcfirst }}) Decode(value interface{}) erro } return nil -} \ No newline at end of file +} diff --git a/templates/go/payload.go.twig b/templates/go/payload.go.twig new file mode 100644 index 000000000..55f2e420e --- /dev/null +++ b/templates/go/payload.go.twig @@ -0,0 +1,66 @@ +package payload + +import ( + "os" + "encoding/json" +) + +type Payload struct { + Name string + Path string + Data []byte +} + +func (p *Payload) ToBinary() []byte { + return p.Data +} + +func (p *Payload) ToFile(path string) error { + return os.WriteFile(path, p.ToBinary(), 0755) +} + +func (p *Payload) ToString() string { + return string(p.ToBinary()) +} + +func (p *Payload) ToJson() map[string]any { + var data map[string]any + + _ = json.Unmarshal(p.ToBinary(), &data) + + return data +} + +func NewPayloadFromFile(path string, name string) *Payload { + return &Payload{ + Name: name, + Path: path, + Data: nil, + } +} + +func NewPayloadFromBinary(data []byte, name string) *Payload { + return &Payload{ + Name: name, + Data: data, + } +} + +func NewPayloadFromJson(data any, name string) *Payload { + marshaled, err := json.Marshal(data) + + if err != nil { + marshaled = nil + } + + return &Payload{ + Name: name, + Data: marshaled, + } +} + +func NewPayloadFromString(data string) *Payload { + return &Payload{ + Data: []byte(data), + } +} diff --git a/templates/go/services/service.go.twig b/templates/go/services/service.go.twig index e5a76818a..bf2c3c9c3 100644 --- a/templates/go/services/service.go.twig +++ b/templates/go/services/service.go.twig @@ -1,12 +1,12 @@ {%- set requireModelsPkg = false -%} -{%- set requireFilesPkg = false -%} +{%- set requirePayloadPkg = false -%} {%- for method in service.methods -%} {%- if (method | returnType(spec, spec.title | caseLower)) starts with "models" -%} {%- set requireModelsPkg = true -%} {%- endif -%} {% for parameter in method.parameters.all %} - {%- if (parameter | typeName) ends with "InputFile" -%} - {%- set requireFilesPkg = true -%} + {%- if (parameter | typeName) ends with "Payload" -%} + {%- set requirePayloadPkg = true -%} {%- endif -%} {% endfor %} {%- endfor -%} @@ -19,8 +19,8 @@ import ( {% if requireModelsPkg %} "github.com/{{sdk.gitUserName}}/sdk-for-go/models" {% endif %} -{% if requireFilesPkg %} - "github.com/{{sdk.gitUserName}}/sdk-for-go/file" +{% if requirePayloadPkg %} + "github.com/{{sdk.gitUserName}}/sdk-for-go/payload" {% endif %} "strings" ) @@ -95,7 +95,7 @@ func (srv *{{ service.name | caseUcfirst }}) {{ method.name | caseUcfirst }}({{ path := "{{ method.path }}" {% endif %} {{include('go/base/params.twig')}} -{% if 'multipart/form-data' in method.consumes %} +{% if 'multipart/form-data' in method.consumes and method.type == "upload" %} {{ include('go/base/requests/file.twig') }} {% else %} {{ include('go/base/requests/api.twig') }} diff --git a/templates/kotlin/docs/java/example.md.twig b/templates/kotlin/docs/java/example.md.twig index 7dea014ae..bf562fc75 100644 --- a/templates/kotlin/docs/java/example.md.twig +++ b/templates/kotlin/docs/java/example.md.twig @@ -1,7 +1,7 @@ import {{ sdk.namespace | caseDot }}.Client; import {{ sdk.namespace | caseDot }}.coroutines.CoroutineCallback; {% if method.parameters.all | filter((param) => param.type == 'file') | length > 0 %} -import {{ sdk.namespace | caseDot }}.models.InputFile; +import {{ sdk.namespace | caseDot }}.models.Payload; {% endif %} import {{ sdk.namespace | caseDot }}.services.{{ service.name | caseUcfirst }}; {% set added = [] %} @@ -55,4 +55,4 @@ Client client = new Client() ); {% endif %} -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/kotlin/docs/kotlin/example.md.twig b/templates/kotlin/docs/kotlin/example.md.twig index 378594c8c..d97c3089f 100644 --- a/templates/kotlin/docs/kotlin/example.md.twig +++ b/templates/kotlin/docs/kotlin/example.md.twig @@ -1,7 +1,7 @@ import {{ sdk.namespace | caseDot }}.Client import {{ sdk.namespace | caseDot }}.coroutines.CoroutineCallback {% if method.parameters.all | filter((param) => param.type == 'file') | length > 0 %} -import {{ sdk.namespace | caseDot }}.models.InputFile +import {{ sdk.namespace | caseDot }}.models.Payload {% endif %} import {{ sdk.namespace | caseDot }}.services.{{ service.name | caseUcfirst }} {% set added = [] %} @@ -43,4 +43,4 @@ val {{ service.name | caseCamel }} = {{ service.name | caseUcfirst }}(client) {% if loop.last %} ) {% endif %} -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig index d970c5101..7988dec5e 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig @@ -3,7 +3,8 @@ package {{ sdk.namespace | caseDot }} import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception import {{ sdk.namespace | caseDot }}.extensions.fromJson import {{ sdk.namespace | caseDot }}.extensions.toJson -import {{ sdk.namespace | caseDot }}.models.InputFile +import {{ sdk.namespace | caseDot }}.extensions.fromMultiPart +import {{ sdk.namespace | caseDot }}.models.Payload import {{ sdk.namespace | caseDot }}.models.UploadProgress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -172,7 +173,7 @@ class Client @JvmOverloads constructor( /** * Prepare the HTTP request - * + * * @param method * @param path * @param headers @@ -240,6 +241,14 @@ class Client @JvmOverloads constructor( ) } } + it.value is Payload -> { + val payload = it.value as Payload + if (payload.sourceType == "path") { + builder.addFormDataPart(it.key, payload.filename, File(payload.path).asRequestBody()) + } else { + builder.addFormDataPart(it.key, payload.toString()) + } + } else -> { builder.addFormDataPart(it.key, it.value.toString()) } @@ -267,7 +276,7 @@ class Client @JvmOverloads constructor( * @param headers * @param params * - * @return [T] + * @return [T] */ @Throws({{ spec.title | caseUcfirst }}Exception::class) suspend fun call( @@ -290,7 +299,7 @@ class Client @JvmOverloads constructor( * @param headers * @param params * - * @return [T] + * @return [T] */ @Throws({{ spec.title | caseUcfirst }}Exception::class) suspend fun redirect( @@ -325,14 +334,14 @@ class Client @JvmOverloads constructor( onProgress: ((UploadProgress) -> Unit)? = null, ): T { var file: RandomAccessFile? = null - val input = params[paramName] as InputFile + val input = params[paramName] as Payload val size: Long = when(input.sourceType) { "path", "file" -> { file = RandomAccessFile(input.path, "r") file.length() } "bytes" -> { - (input.data as ByteArray).size.toLong() + input.toBinary().size.toLong() } else -> throw UnsupportedOperationException() } @@ -340,7 +349,7 @@ class Client @JvmOverloads constructor( if (size < CHUNK_SIZE) { val data = when(input.sourceType) { "file", "path" -> File(input.path).asRequestBody() - "bytes" -> (input.data as ByteArray).toRequestBody(input.mimeType.toMediaType()) + "bytes" -> input.toBinary().toRequestBody(input.mimeType?.toMediaType()) else -> throw UnsupportedOperationException() } params[paramName] = MultipartBody.Part.createFormData( @@ -387,7 +396,7 @@ class Client @JvmOverloads constructor( } else { size - 1 } - (input.data as ByteArray).copyInto( + input.toBinary().copyInto( buffer, startIndex = offset.toInt(), endIndex = end.toInt() @@ -429,7 +438,7 @@ class Client @JvmOverloads constructor( return converter(result as Map) } - /** + /** * Await Redirect * * @param request @@ -456,14 +465,14 @@ class Client @JvmOverloads constructor( .charStream() .buffered() .use(BufferedReader::readText) - + val error = if (response.headers["content-type"]?.contains("application/json") == true) { val map = body.fromJson>() {{ spec.title | caseUcfirst }}Exception( - map["message"] as? String ?: "", + map["message"] as? String ?: "", (map["code"] as Number).toInt(), - map["type"] as? String ?: "", + map["type"] as? String ?: "", body ) } else { @@ -507,14 +516,14 @@ class Client @JvmOverloads constructor( .charStream() .buffered() .use(BufferedReader::readText) - + val error = if (response.headers["content-type"]?.contains("application/json") == true) { val map = body.fromJson>() {{ spec.title | caseUcfirst }}Exception( - map["message"] as? String ?: "", + map["message"] as? String ?: "", (map["code"] as Number).toInt(), - map["type"] as? String ?: "", + map["type"] as? String ?: "", body ) } else { @@ -549,6 +558,14 @@ class Client @JvmOverloads constructor( return } } + if (response.headers["content-type"]?.contains("multipart/form-data") == true) { + val binaryBody = response.body!!.bytes() + val body = String(binaryBody) + val map = body.fromMultiPart(binaryBody) + it.resume(converter?.invoke(map) ?: map as T) + return + } + val body = response.body!! .charStream() .buffered() @@ -557,6 +574,7 @@ class Client @JvmOverloads constructor( it.resume(true as T) return } + val map = body.fromJson>() it.resume( converter?.invoke(map) ?: map as T @@ -564,4 +582,4 @@ class Client @JvmOverloads constructor( } }) } -} \ No newline at end of file +} diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/extensions/TypeExtensions.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/extensions/TypeExtensions.kt.twig index 60ae41788..e4a432a58 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/extensions/TypeExtensions.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/extensions/TypeExtensions.kt.twig @@ -1,8 +1,107 @@ package {{ sdk.namespace | caseDot }}.extensions +import {{ sdk.namespace | caseDot }}.models.Payload import kotlin.reflect.KClass import kotlin.reflect.typeOf inline fun classOf(): Class { return (typeOf().classifier!! as KClass).java -} \ No newline at end of file +} + +fun String.fromMultiPart(binaryBody: ByteArray): Map { + val match = Regex("(-+\\w+)--").find(this) ?: return emptyMap() + // For kotlin + + val boundary = match.groupValues[1] + + var map = + mutableMapOf( + "\$id" to "", + "\$createdAt" to "", + "\$updatedAt" to "", + "\$permissions" to emptyList(), + "functionId" to "", + "trigger" to "", + "status" to "", + "requestMethod" to "", + "requestPath" to "", + "requestHeaders" to emptyList>(), + "responseStatusCode" to 0, + "responseBody" to Payload.fromBinary(ByteArray(0)), + "responseHeaders" to emptyList>(), + "logs" to "", + "errors" to "", + "duration" to 0.0, + "scheduledAt" to "", + ) + + val parts = this.split(boundary) + for (part in parts) { + var lines = part.split("\r\n") + + val name = Regex("name=\"?(\\w+)").find(part) ?: continue + + lines = + lines + .dropWhile { it.isEmpty() } + .drop(1) + .dropWhile { it.isEmpty() } + .dropLastWhile { it.isEmpty() } + val key = name.groupValues[1] + + if (lines.isEmpty()) { + continue + } + + if (key == "responseBody") { + val needle = "name=\"responseBody\"\r\n\r\n" + val indexOf = this.indexOf(needle) + needle.length + val endBytes = "\r\n-------".toByteArray() + + val list = ByteArray(binaryBody.size - indexOf) + val multipart = binaryBody.drop(indexOf) + + var weHitTheEnd = false + var j = 0 + for (i in multipart) { + if (multipart.size > j + endBytes.size) { + var jj = 0 + for (byte in endBytes) { + if (byte != multipart[j + jj]) break + jj++ + if (jj != endBytes.size - 1) continue + weHitTheEnd = true + } + } + if (weHitTheEnd) { + break + } + + list[j] = multipart[j] + j++ + } + + map["responseBody"] = + Payload.fromBinary(list.dropLastWhile { it == 0.toByte() }.toByteArray()) + continue + } + + if (lines[0] == "Content-Type: application/json") { + lines = lines.drop(1).dropWhile { it.isEmpty() } + val list = lines.joinToString("\r\n").fromJson>() + map[key] = list + continue + } + + val value = lines.joinToString("\r\n") + + map[key] = + when (key) { + "responseStatusCode" -> value.toInt() + "duration" -> value.toFloat() + else -> value + } + } + + return map +} diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/models/InputFile.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/models/InputFile.kt.twig deleted file mode 100644 index 382267a0d..000000000 --- a/templates/kotlin/src/main/kotlin/io/appwrite/models/InputFile.kt.twig +++ /dev/null @@ -1,37 +0,0 @@ -package {{ sdk.namespace | caseDot }}.models - -import java.io.File -import java.net.URLConnection -import java.nio.file.Files -import java.nio.file.Paths - -class InputFile private constructor() { - - lateinit var path: String - lateinit var filename: String - lateinit var mimeType: String - lateinit var sourceType: String - lateinit var data: Any - - companion object { - fun fromFile(file: File) = InputFile().apply { - path = file.canonicalPath - filename = file.name - mimeType = Files.probeContentType(Paths.get(file.canonicalPath)) - ?: URLConnection.guessContentTypeFromName(filename) - ?: "" - sourceType = "file" - } - - fun fromPath(path: String): InputFile = fromFile(File(path)).apply { - sourceType = "path" - } - - fun fromBytes(bytes: ByteArray, filename: String = "", mimeType: String = "") = InputFile().apply { - this.filename = filename - this.mimeType = mimeType - data = bytes - sourceType = "bytes" - } - } -} \ No newline at end of file diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/models/Payload.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/models/Payload.kt.twig new file mode 100644 index 000000000..b6f5fcc15 --- /dev/null +++ b/templates/kotlin/src/main/kotlin/io/appwrite/models/Payload.kt.twig @@ -0,0 +1,73 @@ +package io.appwrite.models + +import io.appwrite.extensions.gson +import java.io.File +import java.net.URLConnection +import java.nio.file.Files +import java.nio.file.Paths + +class Payload private constructor() { + + lateinit var path: String + lateinit var filename: String + lateinit var sourceType: String + lateinit var data: Any + var mimeType: String? = null + + override fun toString(): String { + if (sourceType != "bytes") { + throw IllegalArgumentException("source type is not supported: $sourceType") + } + + return String(data as ByteArray) + } + + fun toBinary(): ByteArray { + if (sourceType != "bytes") { + throw IllegalArgumentException("source type is not supported: $sourceType") + } + + return data as ByteArray + } + + fun toJson(): MutableMap { + if (sourceType != "bytes") { + throw IllegalArgumentException("source type is not supported: $sourceType") + } + + return gson.fromJson(toString(), MutableMap::class.java) as MutableMap + } + + fun toFile(path: String): File { + Files.createDirectories(Paths.get(path).parent); + + val file = File(path) + file.appendBytes(toBinary()) + return file + } + + companion object { + fun fromFile(path: String, filename: String = ""): Payload = fromFileObject(File(path), filename).apply { + sourceType = "path" + } + + fun fromBinary(bytes: ByteArray, filename: String = "") = Payload().apply { + this.filename = filename + data = bytes + sourceType = "bytes" + } + + fun fromString(string: String) = fromBinary(string.toByteArray()) + + fun fromJson(data: Any) = fromString(gson.toJson(data)) + + fun fromFileObject(file: File, name: String = "") = Payload().apply { + path = file.canonicalPath + filename = if (name != "") name else file.name + mimeType = Files.probeContentType(Paths.get(file.canonicalPath)) + ?: URLConnection.guessContentTypeFromName(filename) + ?: "" + sourceType = "file" + } + } +} 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 c5add5d0f..7473618e3 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 @@ -57,9 +57,12 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, {%~ endfor %} ) - val apiHeaders = mutableMapOf( + val apiHeaders = mutableMapOf( {%~ for key, header in method.headers %} "{{ key }}" to "{{ header }}", + {%~ if 'multipart/form-data' in method.consumes and method.type != "upload" %} + "accept" to "multipart/form-data", + {%~ endif %} {%~ endfor %} ) {%~ if method.type == 'location' %} @@ -76,7 +79,7 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { {%~ endif %} } {%~ endif %} - {%~ if 'multipart/form-data' in method.consumes %} + {%~ if 'multipart/form-data' in method.consumes and method.type == "upload" %} {{~ include('kotlin/base/requests/file.twig') }} {%~ else %} {{~ include('kotlin/base/requests/api.twig') }} @@ -123,4 +126,4 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { {%~ endif %} {%~ endfor %} -} \ No newline at end of file +} diff --git a/templates/node/package.json.twig b/templates/node/package.json.twig index ca6435c13..7268dafe0 100644 --- a/templates/node/package.json.twig +++ b/templates/node/package.json.twig @@ -19,16 +19,6 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" } - }, - "./file": { - "import": { - "types": "./dist/inputFile.d.mts", - "default": "./dist/inputFile.mjs" - }, - "require": { - "types": "./dist/inputFile.d.ts", - "default": "./dist/inputFile.js" - } } }, "files": [ @@ -48,6 +38,7 @@ "typescript": "5.4.2" }, "dependencies": { - "node-fetch-native-with-agent": "1.7.2" + "node-fetch-native-with-agent": "1.7.2", + "parse-multipart-data": "^1.5.0" } } diff --git a/templates/node/src/client.ts.twig b/templates/node/src/client.ts.twig index 7dffac1cf..63b198d70 100644 --- a/templates/node/src/client.ts.twig +++ b/templates/node/src/client.ts.twig @@ -1,8 +1,10 @@ -import { fetch, FormData, File } from 'node-fetch-native-with-agent'; +import { fetch, FormData, Blob } from 'node-fetch-native-with-agent'; +import { getBoundary, parse as parseMultipart} from 'parse-multipart-data'; import { createAgent } from 'node-fetch-native-with-agent/agent'; import { Models } from './models'; +import { Payload } from './payload'; -type Payload = { +type Params = { [key: string]: any; } @@ -152,7 +154,7 @@ class Client { } {%~ endfor %} - prepareRequest(method: string, url: URL, headers: Headers = {}, params: Payload = {}): { uri: string, options: RequestInit } { + prepareRequest(method: string, url: URL, headers: Headers = {}, params: Params = {}): { uri: string, options: RequestInit } { method = method.toUpperCase(); headers = Object.assign({}, this.headers, headers); @@ -177,8 +179,8 @@ class Client { const formData = new FormData(); for (const [key, value] of Object.entries(params)) { - if (value instanceof File) { - formData.append(key, value, value.name); + if (value instanceof Payload) { + formData.append(key, new Blob([value.toBinary()]), value.filename); } else if (Array.isArray(value)) { for (const nestedValue of value) { formData.append(`${key}[]`, nestedValue); @@ -190,6 +192,7 @@ class Client { options.body = formData; delete headers['content-type']; + headers['accept'] = 'multipart/form-data'; break; } } @@ -197,8 +200,18 @@ class Client { return { uri: url.toString(), options }; } - async chunkedUpload(method: string, url: URL, headers: Headers = {}, originalPayload: Payload = {}, onProgress: (progress: UploadProgress) => void) { - const file = Object.values(originalPayload).find((value) => value instanceof File); + async chunkedUpload(method: string, url: URL, headers: Headers = {}, originalPayload: Params = {}, onProgress: (progress: UploadProgress) => void) { + let file; + for (const value of Object.values(originalPayload)) { + if (value instanceof Payload) { + file = value; + break; + } + } + + if (!file) { + throw new Error('No payload found in params'); + } if (file.size <= Client.CHUNK_SIZE) { return await this.call(method, url, headers, originalPayload); @@ -214,9 +227,9 @@ class Client { } headers['content-range'] = `bytes ${start}-${end-1}/${file.size}`; - const chunk = file.slice(start, end); + const chunk = file.toBinary(start, end - start); - let payload = { ...originalPayload, file: new File([chunk], file.name)}; + let payload = { ...originalPayload, file: new Payload(Buffer.from(chunk), file.filename)}; response = await this.call(method, url, headers, payload); @@ -244,7 +257,7 @@ class Client { return this.call('GET', new URL(this.config.endpoint + '/ping')); } - async redirect(method: string, url: URL, headers: Headers = {}, params: Payload = {}): Promise { + async redirect(method: string, url: URL, headers: Headers = {}, params: Params = {}): Promise { const { uri, options } = this.prepareRequest(method, url, headers, params); const response = await fetch(uri, { @@ -259,7 +272,7 @@ class Client { return response.headers.get('location') || ''; } - async call(method: string, url: URL, headers: Headers = {}, params: Payload = {}, responseType = 'json'): Promise { + async call(method: string, url: URL, headers: Headers = {}, params: Params = {}, responseType = 'json'): Promise { const { uri, options } = this.prepareRequest(method, url, headers, params); let data: any = null; @@ -275,6 +288,35 @@ class Client { data = await response.json(); } else if (responseType === 'arrayBuffer') { data = await response.arrayBuffer(); + } else if (response.headers.get('content-type')?.includes('multipart/form-data')) { + const body = await response.arrayBuffer(); + const boundary = getBoundary( + response.headers.get("content-type") || "" + ); + const parts = parseMultipart(Buffer.from(body), boundary); + const partsObject: { [key: string]: any } = {}; + + for (const part of parts) { + if (!part.name) { + continue; + } + if (part.name === "responseBody") { + partsObject[part.name] = Payload.fromBinary(part.data, part.filename); + } else if (part.name === "responseStatusCode") { + partsObject[part.name] = parseInt(part.data.toString()); + } else if (part.name === "duration") { + partsObject[part.name] = parseFloat(part.data.toString()); + } else if (part.type === 'application/json') { + try { + partsObject[part.name] = JSON.parse(part.data.toString()); + } catch (e) { + throw new Error(`Error parsing JSON for part ${part.name}: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + } else { + partsObject[part.name] = part.data.toString(); + } + } + data = partsObject; } else { data = { message: await response.text() @@ -288,8 +330,8 @@ class Client { return data; } - static flatten(data: Payload, prefix = ''): Payload { - let output: Payload = {}; + static flatten(data: Params, prefix = ''): Params { + let output: Params = {}; for (const [key, value] of Object.entries(data)) { let finalKey = prefix ? prefix + '[' + key +']' : key; @@ -306,5 +348,5 @@ class Client { export { Client, {{spec.title | caseUcfirst}}Exception }; export { Query } from './query'; -export type { Models, Payload, UploadProgress }; +export type { Models, Params, UploadProgress }; export type { QueryTypes, QueryTypesList } from './query'; diff --git a/templates/node/src/index.ts.twig b/templates/node/src/index.ts.twig index 769354991..99dfea2a5 100644 --- a/templates/node/src/index.ts.twig +++ b/templates/node/src/index.ts.twig @@ -2,11 +2,12 @@ export { Client, Query, {{spec.title | caseUcfirst}}Exception } from './client'; {% for service in spec.services %} export { {{service.name | caseUcfirst}} } from './services/{{service.name | caseDash}}'; {% endfor %} -export type { Models, Payload, UploadProgress } from './client'; +export type { Models, Params, UploadProgress } from './client'; export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; +export { Payload } from './payload'; {% for enum in spec.enums %} export { {{ enum.name | caseUcfirst }} } from './enums/{{enum.name | caseDash}}'; {% endfor %} diff --git a/templates/node/src/inputFile.ts.twig b/templates/node/src/inputFile.ts.twig deleted file mode 100644 index a30ea55d2..000000000 --- a/templates/node/src/inputFile.ts.twig +++ /dev/null @@ -1,23 +0,0 @@ -import { File } from "node-fetch-native-with-agent"; -import { realpathSync, readFileSync } from "fs"; -import type { BinaryLike } from "crypto"; - -export class InputFile { - static fromBuffer( - parts: Blob | BinaryLike, - name: string - ): File { - return new File([parts], name); - } - - static fromPath(path: string, name: string): File { - const realPath = realpathSync(path); - const contents = readFileSync(realPath); - return this.fromBuffer(contents, name); - } - - static fromPlainText(content: string, name: string): File { - const arrayBytes = new TextEncoder().encode(content); - return this.fromBuffer(arrayBytes, name); - } -} diff --git a/templates/node/src/payload.ts.twig b/templates/node/src/payload.ts.twig new file mode 100644 index 000000000..1bb88588f --- /dev/null +++ b/templates/node/src/payload.ts.twig @@ -0,0 +1,43 @@ +export class Payload { + private data: Buffer; + public filename?: string; + public size: number; + + constructor(data: Buffer, filename?: string) { + this.data = data; + this.filename = filename; + this.size = data.byteLength; + } + + public toBinary(offset: number = 0, length?: number): Buffer { + if (offset === 0 && length === undefined) { + return this.data; + } else if (length === undefined) { + return this.data.subarray(offset); + } else { + return this.data.subarray(offset, offset + length); + } + } + + public toJson(): T { + return JSON.parse(this.toString()); + } + + public toString(): string { + return this.data.toString("utf-8"); + } + + public static fromBinary(bytes: Buffer, filename?: string): Payload { + return new Payload(bytes, filename); + } + + public static fromJson(object: any, filename?: string): Payload { + const data = Buffer.from(JSON.stringify(object), "utf-8"); + return new Payload(data, filename); + } + + public static fromString(text: string, filename?: string): Payload { + const data = Buffer.from(text, "utf-8"); + return new Payload(data, filename); + } +} diff --git a/templates/node/src/services/template.ts.twig b/templates/node/src/services/template.ts.twig index 47a20cdc7..7b199d31c 100644 --- a/templates/node/src/services/template.ts.twig +++ b/templates/node/src/services/template.ts.twig @@ -1,4 +1,5 @@ -import { {{ spec.title | caseUcfirst}}Exception, Client, type Payload, UploadProgress } from '../client'; +import { {{ spec.title | caseUcfirst}}Exception, Client, type Params, UploadProgress } from '../client'; +import { Payload } from '../payload'; import type { Models } from '../models'; {% set added = [] %} {% for method in service.methods %} @@ -47,7 +48,7 @@ export class {{ service.name | caseUcfirst }} { {%~ endif %} {%~ endfor %} const apiPath = '{{ method.path }}'{% for parameter in method.parameters.path %}.replace('{{ '{' }}{{ parameter.name | caseCamel | escapeKeyword }}{{ '}' }}', {{ parameter.name | caseCamel | escapeKeyword }}){% endfor %}; - const payload: Payload = {}; + const payload: Params = {}; {%~ for parameter in method.parameters.query %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} !== 'undefined') { payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; @@ -55,7 +56,11 @@ export class {{ service.name | caseUcfirst }} { {%~ endfor %} {%~ for parameter in method.parameters.body %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} !== 'undefined') { + {%~ if parameter.type == 'payload' %} + payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}.toBinary(); + {%~ else %} payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; + {%~ endif %} } {%~ endfor %} const uri = new URL(this.client.config.endpoint + apiPath); @@ -76,7 +81,7 @@ export class {{ service.name | caseUcfirst }} { apiHeaders, payload ); - {%~ elseif 'multipart/form-data' in method.consumes %} + {%~ elseif 'multipart/form-data' in method.consumes and method.type == "upload" %} return await this.client.chunkedUpload( '{{ method.method | caseLower }}', uri, diff --git a/templates/php/base/params.twig b/templates/php/base/params.twig index 60aaabdb6..036ad35d8 100644 --- a/templates/php/base/params.twig +++ b/templates/php/base/params.twig @@ -4,7 +4,11 @@ {% if not parameter.required and not parameter.nullable %} if (!is_null(${{ parameter.name | caseCamel | escapeKeyword }})) { - $apiParams['{{ parameter.name }}'] = ${{ parameter.name | caseCamel | escapeKeyword }}; + {%~ if param.type == 'payload' %} + $apiParams['{{ parameter.name }}'] = ${{ parameter.name | caseCamel | escapeKeyword }}->toBinary(); + {%~ else %} + $apiParams['{{ parameter.name }}'] = ${{ parameter.name | caseCamel | escapeKeyword }}; + {%~ endif %} } {% else %} $apiParams['{{ parameter.name }}'] = ${{ parameter.name | caseCamel | escapeKeyword }}; diff --git a/templates/php/base/requests/api.twig b/templates/php/base/requests/api.twig index 473b79211..adc42ac4e 100644 --- a/templates/php/base/requests/api.twig +++ b/templates/php/base/requests/api.twig @@ -4,4 +4,4 @@ $apiHeaders, $apiParams{% if method.type == 'webAuth' -%}, 'location'{% endif %} - ); \ No newline at end of file + ); diff --git a/templates/php/base/requests/file.twig b/templates/php/base/requests/file.twig index e80c793da..01ebf296c 100644 --- a/templates/php/base/requests/file.twig +++ b/templates/php/base/requests/file.twig @@ -1,46 +1,23 @@ -{% for parameter in method.parameters.all %} -{% if parameter.type == 'file' %} - $size = 0; - $mimeType = null; - $postedName = null; - if(empty(${{ parameter.name | caseCamel }}->getPath() ?? null)) { - $size = strlen(${{ parameter.name | caseCamel }}->getData()); - $mimeType = ${{ parameter.name | caseCamel }}->getMimeType(); - $postedName = ${{ parameter.name | caseCamel }}->getFilename(); - if ($size <= Client::CHUNK_SIZE) { - $apiParams['{{ parameter.name | caseCamel }}'] = new \CURLFile('data://' . $mimeType . ';base64,' . base64_encode(${{ parameter.name | caseCamel }}->getData()), $mimeType, $postedName); - return $this->client->call(Client::METHOD_POST, $apiPath, [ - {% for param in method.parameters.header %} - '{{ param.name }}' => ${{ param.name | caseCamel }}, - {% endfor %} - {% for key, header in method.headers %} - '{{ key }}' => '{{ header }}', - {% endfor %} - ], $apiParams); - } - } else { - $size = filesize(${{ parameter.name | caseCamel }}->getPath()); - $mimeType = ${{ parameter.name | caseCamel }}->getMimeType() ?? mime_content_type(${{ parameter.name | caseCamel }}->getPath()); - $postedName = ${{ parameter.name | caseCamel }}->getFilename() ?? basename(${{ parameter.name | caseCamel }}->getPath()); - //send single file if size is less than or equal to 5MB - if ($size <= Client::CHUNK_SIZE) { - $apiParams['{{ parameter.name }}'] = new \CURLFile(${{ parameter.name | caseCamel }}->getPath(), $mimeType, $postedName); - return $this->client->call(Client::METHOD_{{ method.method | caseUpper }}, $apiPath, [ - {% for param in method.parameters.header %} - '{{ param.name }}' => ${{ param.name | caseCamel }}, - {% endfor %} - {% for key, header in method.headers %} - '{{ key }}' => '{{ header }}', - {% endfor %} - ], $apiParams); - } + {%~ for parameter in method.parameters.all %} + {%~ if parameter.type == 'file' %} + $size = ${{ parameter.name | caseCamel }}->size; + + if ($size <= Client::CHUNK_SIZE) { + return $this->client->call(Client::METHOD_POST, $apiPath, [ + {%~ for param in method.parameters.header %} + '{{ param.name }}' => ${{ param.name | caseCamel }}, + {%~ endfor %} + {%~ for key, header in method.headers %} + '{{ key }}' => '{{ header }}', + {%~ endfor %} + ], $apiParams); } $id = ''; $counter = 0; -{% for parameter in method.parameters.all %} -{% if parameter.isUploadID %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.isUploadID %} if(${{ parameter.name | caseCamel | escapeKeyword }} != 'unique()') { try { $response = $this->client->call(Client::METHOD_GET, $apiPath . '/' . ${{ parameter.name }}); @@ -48,26 +25,20 @@ } catch(\Exception $e) { } } -{% endif %} -{% endfor %} + {%~ endif %} + {%~ endfor %} $apiHeaders = ['content-type' => 'multipart/form-data']; - $handle = null; - - if(!empty(${{parameter.name}}->getPath())) { - $handle = @fopen(${{parameter.name}}->getPath(), "rb"); - } $start = $counter * Client::CHUNK_SIZE; while ($start < $size) { - $chunk = ''; - if(!empty($handle)) { - fseek($handle, $start); - $chunk = @fread($handle, Client::CHUNK_SIZE); - } else { - $chunk = substr($file->getData(), $start, Client::CHUNK_SIZE); - } - $apiParams['{{ parameter.name }}'] = new \CURLFile('data://' . $mimeType . ';base64,' . base64_encode($chunk), $mimeType, $postedName); + + $apiParams['{{ parameter.name }}'] = Payload::fromBinary( + ${{ parameter.name | caseCamel | escapeKeyword }}->toBinary($start, Client::CHUNK_SIZE), + ${{ parameter.name | caseCamel | escapeKeyword }}->filename, + ${{ parameter.name | caseCamel | escapeKeyword }}->mimeType + ); + $apiHeaders['content-range'] = 'bytes ' . ($counter * Client::CHUNK_SIZE) . '-' . min(((($counter * Client::CHUNK_SIZE) + Client::CHUNK_SIZE) - 1), $size - 1) . '/' . $size; if(!empty($id)) { $apiHeaders['x-{{spec.title | caseLower }}-id'] = $id; @@ -84,13 +55,10 @@ 'progress' => min(((($counter * Client::CHUNK_SIZE) + Client::CHUNK_SIZE)), $size) / $size * 100, 'sizeUploaded' => min($counter * Client::CHUNK_SIZE), 'chunksTotal' => $response['chunksTotal'], - 'chunksUploaded' => $response['chunksUploaded'], + 'chunksUploaded' => $response['chunksUploaded'], ]); } } - if(!empty($handle)) { - @fclose($handle); - } return $response; -{% endif %} -{% endfor %} \ No newline at end of file + {%~ endif %} + {%~ endfor %} diff --git a/templates/php/docs/example.md.twig b/templates/php/docs/example.md.twig index 9a272432b..dd805524d 100644 --- a/templates/php/docs/example.md.twig +++ b/templates/php/docs/example.md.twig @@ -1,8 +1,8 @@ param.type == 'file') | length > 0 %} -use {{ spec.title | caseUcfirst }}\InputFile; +{% if method.parameters.all | filter((param) => param.type == 'file' or param.type == 'payload') | length > 0 %} +use {{ spec.title | caseUcfirst }}\Payload; {% endif %} use {{ spec.title | caseUcfirst }}\Services\{{ service.name | caseUcfirst }}; {% set added = [] %} diff --git a/templates/php/src/Client.php.twig b/templates/php/src/Client.php.twig index 894866571..e0fff4887 100644 --- a/templates/php/src/Client.php.twig +++ b/templates/php/src/Client.php.twig @@ -51,7 +51,7 @@ class Client { {% for key,header in spec.global.defaultHeaders %} $this->headers['{{key}}'] = '{{header}}'; -{% endfor %} +{% endfor %} } {% for header in spec.global.headers %} @@ -104,7 +104,7 @@ class Client public function addHeader(string $key, string $value): Client { $this->headers[strtolower($key)] = $value; - + return $this; } @@ -138,6 +138,7 @@ class Client break; case 'multipart/form-data': + $headers['accept'] = 'multipart/form-data'; $query = $this->flatten($params); break; @@ -189,17 +190,23 @@ class Client echo 'Warning: ' . $warning . PHP_EOL; } } - + switch(substr($contentType, 0, strpos($contentType, ';'))) { case 'application/json': $responseBody = json_decode($responseBody, true); break; } - + if (str_contains($contentType, 'multipart/form-data')) { + $matches = []; + preg_match('/(?[-]+[\w]+)--/m', $responseBody, $matches); + if (isset($matches['boundary'])) { + $responseBody = self::handleFormData($matches['boundary'], $responseBody); + } + } if (curl_errno($ch)) { throw new {{spec.title | caseUcfirst}}Exception(curl_error($ch), $responseStatus, $responseBody['type'] ?? '', $responseBody); } - + curl_close($ch); if($responseStatus >= 400) { @@ -230,14 +237,76 @@ class Client foreach($data as $key => $value) { $finalKey = $prefix ? "{$prefix}[{$key}]" : $key; - if (is_array($value)) { - $output += $this->flatten($value, $finalKey); // @todo: handle name collision here if needed - } - else { + if ($value instanceof Payload) { + if ($value->filename) { + if (class_exists('\CURLStringFile')) { + // Use CURLStringFile for in-memory data (PHP 8.1+) + $output[$finalKey] = new \CURLStringFile( + $value->toBinary(), + $value->filename, + $value->mimeType + ); + } else { + // For PHP versions < 8.1, write data to a temporary file + $tmpfname = tempnam(sys_get_temp_dir(), 'upload'); + file_put_contents($tmpfname, $value->toBinary()); + $output[$finalKey] = new \CURLFile( + $tmpfname, + $value->mimeType, + $value->filename + ); + } + } else { + $output[$finalKey] = $value->toBinary(); + } + } else if (is_array($value)) { + $output += $this->flatten($value, $finalKey); + } else { $output[$finalKey] = $value; } } return $output; } + + public static function handleFormData(string $boundary, mixed $responseBody) + { + $parts = explode($boundary, $responseBody); + $data = []; + foreach ($parts as $part) { + $lines = array_values(array_filter(explode("\r\n", $part))); + $matches = []; + $matched = preg_match('/name="?(?\w+)/s', $part, $matches); + if ($matched) { + array_shift($lines); + if(isset($lines[0]) && $lines[0] === 'Content-Type: application/json'){ + array_shift($lines); + $json = json_decode(implode($lines), true); + + if (count($json) > 0 && isset($json[0]['name']) && isset($json[0]['value'])) { + $json = array_combine( + array_map(fn($header) => $header['name'], $json), + array_map(fn($header) => $header['value'], $json) + ); + } + + $data[$matches['name']] = $json; + continue; + } + $data[$matches['name']] = implode("\r\n",$lines) ?? '';; + } + } + + if(isset($data['responseStatusCode'])) { + $data['responseStatusCode'] = (int) ($data['responseStatusCode'] ?? ''); + } + if(isset($data['duration'])) { + $data['duration'] = ((float) ($data['duration'] ?? '')); + } + if(isset($data['responseBody'])) { + $data['responseBody'] = Payload::fromBinary($data['responseBody'] ?? ''); + } + + return $data; + } } diff --git a/templates/php/src/InputFile.php.twig b/templates/php/src/InputFile.php.twig deleted file mode 100644 index 50844f27d..000000000 --- a/templates/php/src/InputFile.php.twig +++ /dev/null @@ -1,51 +0,0 @@ -data; - } - - public function getPath(): ?string - { - return $this->path; - } - - public function getMimeType(): ?string - { - return $this->mimeType; - } - - public function getFilename(): ?string - { - return $this->filename; - } - - public static function withPath(string $path, ?string $mimeType = null, ?string $filename = null): InputFile - { - $instance = new InputFile(); - $instance->path = $path; - $instance->data = null; - $instance->mimeType = $mimeType; - $instance->filename = $filename; - return $instance; - } - - public static function withData(string $data, ?string $mimeType = null, ?string $filename = null): InputFile - { - $instance = new InputFile(); - $instance->path = null; - $instance->data = $data; - $instance->mimeType = $mimeType; - $instance->filename = $filename; - return $instance; - } -} \ No newline at end of file diff --git a/templates/php/src/Payload.php.twig b/templates/php/src/Payload.php.twig new file mode 100644 index 000000000..ba13ce1c9 --- /dev/null +++ b/templates/php/src/Payload.php.twig @@ -0,0 +1,74 @@ +path = $path; + $instance->data = null; + return $instance; + } + + public static function fromBinary(string $data, ?string $filename = null, ?string $mimeType = null): self + { + $instance = new Payload(strlen($data), $filename, $mimeType); + $instance->path = null; + $instance->data = $data; + return $instance; + } + + public static function fromJson(array $data): self + { + $data = json_encode($data); + return self::fromString($data); + } + + public static function fromString(string $data): self + { + return self::fromBinary($data); + } + + public function toBinary(?int $offset = 0, ?int $length = null): string + { + $length = $length ?? ($this->size - $offset); + if ($this->data) { + return substr($this->data, $offset, $length); + } else { + return file_get_contents($this->path, false, null, $offset, $length); + } + } + + public function toJson(): mixed + { + return json_decode($this->data, true); + } + + public function toString(): string + { + return $this->data; + } + + public function toFile(string $path): void + { + file_put_contents($path, $this->data); + } +} diff --git a/templates/php/src/Services/Service.php.twig b/templates/php/src/Services/Service.php.twig index b524cb6a4..3970388cd 100644 --- a/templates/php/src/Services/Service.php.twig +++ b/templates/php/src/Services/Service.php.twig @@ -5,7 +5,7 @@ namespace {{ spec.title | caseUcfirst }}\Services; use {{ spec.title | caseUcfirst }}\{{spec.title | caseUcfirst}}Exception; use {{ spec.title | caseUcfirst }}\Client; use {{ spec.title | caseUcfirst }}\Service; -use {{ spec.title | caseUcfirst }}\InputFile; +use {{ spec.title | caseUcfirst }}\Payload; {% set added = [] %} {% for method in service.methods %} {% for parameter in method.parameters.all %} @@ -53,7 +53,7 @@ class {{ service.name | caseUcfirst }} extends Service ); {{~ include('php/base/params.twig') -}} - {%~ if 'multipart/form-data' in method.consumes %} + {%~ if 'multipart/form-data' in method.consumes and method.type == "upload" %} {{~ include('php/base/requests/file.twig') }} {%~ else %} @@ -64,4 +64,4 @@ class {{ service.name | caseUcfirst }} extends Service {%~ endif %} {%~ endfor %} -} \ No newline at end of file +} diff --git a/templates/php/tests/Services/ServiceTest.php.twig b/templates/php/tests/Services/ServiceTest.php.twig index 6e31e9bf3..eb78af1c9 100644 --- a/templates/php/tests/Services/ServiceTest.php.twig +++ b/templates/php/tests/Services/ServiceTest.php.twig @@ -3,7 +3,7 @@ namespace Appwrite\Services; use Appwrite\Client; -use Appwrite\InputFile; +use Appwrite\Payload; use Mockery; use PHPUnit\Framework\TestCase; @@ -34,7 +34,7 @@ final class {{service.name | caseUcfirst}}Test extends TestCase { ->andReturn($data); $response = $this->{{service.name | caseCamel}}->{{method.name | caseCamel}}({%~ for parameter in method.parameters.all | filter((param) => param.required) ~%} - {% if parameter.type == 'object' %}array(){% elseif parameter.type == 'array' %}array(){% elseif parameter.type == 'file' %}InputFile::withData('', "image/png"){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}"{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}"{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%}{% if not loop.last %},{% endif %}{%~ endfor ~%} + {% if parameter.type == 'object' %}array(){% elseif parameter.type == 'array' %}array(){% elseif parameter.type == 'file' %}Payload::fromBinary('', "image/png"){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}"{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}"{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%}{% if not loop.last %},{% endif %}{%~ endfor ~%} ); $this->assertSame($data, $response); diff --git a/templates/python/base/requests/api.twig b/templates/python/base/requests/api.twig index 82ef6299f..661e7c24f 100644 --- a/templates/python/base/requests/api.twig +++ b/templates/python/base/requests/api.twig @@ -1,8 +1,8 @@ return self.client.call('{{ method.method | caseLower }}', api_path, { -{% for parameter in method.parameters.header %} + {%~ for parameter in method.parameters.header %} '{{ parameter.name }}': {{ parameter.name | escapeKeyword | caseSnake }}, -{% endfor %} -{% for key, header in method.headers %} + {%- endfor %} + {%~ for key, header in method.headers %} '{{ key }}': '{{ header }}', -{% endfor %} + {%~ endfor %} }, api_params{% if method.type == 'webAuth' %}, response_type='location'{% endif %}) \ No newline at end of file diff --git a/templates/python/base/requests/file.twig b/templates/python/base/requests/file.twig index 52b3cc691..bdec8c34a 100644 --- a/templates/python/base/requests/file.twig +++ b/templates/python/base/requests/file.twig @@ -1,22 +1,22 @@ -{% for parameter in method.parameters.all %} -{% if parameter.type == 'file' %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.type == 'file' %} param_name = '{{ parameter.name }}' -{% endif %} -{% endfor %} + {%- endif %} + {%- endfor %} upload_id = '' -{% for parameter in method.parameters.all %} -{% if parameter.isUploadID %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.isUploadID %} upload_id = {{ parameter.name | escapeKeyword | caseSnake }} -{% endif %} -{% endfor %} + {%- endif %} + {%- endfor %} return self.client.chunked_upload(api_path, { -{% for parameter in method.parameters.header %} + {%~ for parameter in method.parameters.header %} '{{ parameter.name }}': {{ parameter.name | escapeKeyword | caseSnake }}, -{% endfor %} -{% for key, header in method.headers %} + {%- endfor %} + {%~ for key, header in method.headers %} '{{ key }}': '{{ header }}', -{% endfor %} + {%~ endfor %} }, api_params, param_name, on_progress, upload_id) \ No newline at end of file diff --git a/templates/python/docs/example.md.twig b/templates/python/docs/example.md.twig index 813976f1c..554b4f334 100644 --- a/templates/python/docs/example.md.twig +++ b/templates/python/docs/example.md.twig @@ -1,6 +1,6 @@ from {{ spec.title | caseSnake }}.client import Client {% if method.parameters.all | filter((param) => param.type == 'file') | length > 0 %} -from {{ spec.title | caseSnake }}.input_file import InputFile +from {{ spec.title | caseSnake }}.payload import Payload {% endif %} {% set added = [] %} {% for parameter in method.parameters.all %} diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index c7a3d4feb..b032b861d 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -1,8 +1,7 @@ -import io import json -import os import requests -from .input_file import InputFile +from .payload import Payload +from .multipart import MultipartParser from .exception import {{spec.title | caseUcfirst}}Exception from .encoders.value_class_encoder import ValueClassEncoder @@ -69,11 +68,15 @@ class Client: if headers['content-type'].startswith('multipart/form-data'): del headers['content-type'] + headers['accept'] = 'multipart/form-data' stringify = True for key in data.copy(): - if isinstance(data[key], InputFile): - files[key] = (data[key].filename, data[key].data) - del data[key] + if isinstance(data[key], Payload): + if data[key].filename: + files[key] = (data[key].filename, data[key].to_binary()) + del data[key] + else: + data[key] = data[key].to_string() data = self.flatten(data, stringify=stringify) response = None @@ -104,6 +107,9 @@ class Client: if content_type.startswith('application/json'): return response.json() + if content_type.startswith('multipart/form-data'): + return MultipartParser(response.content, content_type).to_dict() + return response._content except Exception as e: if response != None: @@ -124,20 +130,10 @@ class Client: on_progress = None, upload_id = '' ): - input_file = params[param_name] - - if input_file.source_type == 'path': - size = os.stat(input_file.path).st_size - input = open(input_file.path, 'rb') - elif input_file.source_type == 'bytes': - size = len(input_file.data) - input = input_file.data - - if size < self._chunk_size: - if input_file.source_type == 'path': - input_file.data = input.read() + payload = params[param_name] + size = params[param_name].size - params[param_name] = input_file + if size < self._chunk_size: return self.call( 'post', path, @@ -160,16 +156,10 @@ class Client: input.seek(offset) while offset < size: - if input_file.source_type == 'path': - input_file.data = input.read(self._chunk_size) or input.read(size - offset) - elif input_file.source_type == 'bytes': - if offset + self._chunk_size < size: - end = offset + self._chunk_size - else: - end = size - offset - input_file.data = input[offset:end] - - params[param_name] = input_file + params[param_name] = Payload.from_binary( + payload.to_binary(offset, min(self._chunk_size, size - offset)), + payload.filename + ) headers["content-range"] = f'bytes {offset}-{min((offset + self._chunk_size) - 1, size - 1)}/{size}' result = self.call( diff --git a/templates/python/package/input_file.py.twig b/templates/python/package/input_file.py.twig deleted file mode 100644 index 33d5a7775..000000000 --- a/templates/python/package/input_file.py.twig +++ /dev/null @@ -1,21 +0,0 @@ -import os -import mimetypes - -class InputFile: - @classmethod - def from_path(cls, path): - instance = cls() - instance.path = path - instance.filename = os.path.basename(path) - instance.mime_type = mimetypes.guess_type(path) - instance.source_type = 'path' - return instance - - @classmethod - def from_bytes(cls, bytes, filename, mime_type = None): - instance = cls() - instance.data = bytes - instance.filename = filename - instance.mime_type = mime_type - instance.source_type = 'bytes' - return instance \ No newline at end of file diff --git a/templates/python/package/multipart.py.twig b/templates/python/package/multipart.py.twig new file mode 100644 index 000000000..f2d53604e --- /dev/null +++ b/templates/python/package/multipart.py.twig @@ -0,0 +1,49 @@ +from email.parser import BytesParser +from email.policy import default +from .payload import Payload +import json + +class MultipartParser: + def __init__(self, multipart_bytes, content_type): + self.multipart_bytes = multipart_bytes + self.content_type = content_type + self.parts = {} + self.parse() + + def parse(self): + # Create a message object + headers = f'Content-Type: {self.content_type}\r\n\r\n'.encode('ascii') + msg = BytesParser(policy=default).parsebytes(headers + self.multipart_bytes) + + # Process each part + for part in msg.walk(): + if part.is_multipart(): + continue + + # Get the name from Content-Disposition + content_disposition = part.get("Content-Disposition", "") + name = part.get_param("name", header="content-disposition") + if not name: + name = f"unnamed_part_{len(self.parts)}" + + # Store the parsed data + self.parts[name] = { + "contents": part.get_payload(decode=True), + "headers": dict(part.items()) + } + + def to_dict(self): + result = {} + for name, part in self.parts.items(): + if name == "responseBody": + result[name] = Payload.from_binary(part["contents"]) + elif name == "responseHeaders": + headers_str = part["contents"].decode('utf-8', errors='replace') + result[name] = json.loads(headers_str) + elif name == "responseStatusCode": + result[name] = int(part["contents"]) + elif name == "duration": + result[name] = float(part["contents"]) + else: + result[name] = part["contents"].decode('utf-8', errors='replace') + return result \ No newline at end of file diff --git a/templates/python/package/payload.py.twig b/templates/python/package/payload.py.twig new file mode 100644 index 000000000..93249b930 --- /dev/null +++ b/templates/python/package/payload.py.twig @@ -0,0 +1,72 @@ +from typing import Optional, Dict, Any +import os, json + +class Payload: + filename: Optional[str] = None + + _path: Optional[str] = None + _data: Optional[bytes] = None + _size: int = 0 + + @property + def size(self) -> int: + return self._size + + def __init__(self, path: Optional[str] = None, data: Optional[bytes] = None, filename: Optional[str] = None): + if path is None and data is None: + raise ValueError("One of path or data must be provided") + + self._path = path + self._data = data + + self.filename = filename + if self._data is None: + self._size = os.path.getsize(self._path) + else: + self._size = len(self._data) + + def to_binary(self, offset: Optional[int] = 0, length: Optional[int] = None) -> bytes: + if length is None: + length = self._size + + if self._data is None: + with open(self._path, 'rb') as f: + f.seek(offset) + return f.read(length) + + return self._data[offset:offset + length] + + def to_string(self, encoding="utf-8") -> str: + return self.to_binary().decode(encoding) + + def __str__(self) -> str: + return self.to_string() + + def to_json(self) -> Dict[str, Any]: + return json.loads(self.to_string()) + + def to_file(self, path: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, 'wb') as f: + return f.write(self.to_binary()) + + @classmethod + def from_binary(cls, data: bytes, filename: Optional[str] = None) -> 'Payload': + return cls(data=data, filename=filename) + + @classmethod + def from_string(cls, data: str) -> 'Payload': + return cls(data=data.encode()) + + @classmethod + def from_file(cls, path: str, filename: Optional[str] = None) -> 'Payload': + if not os.path.exists(path): + raise FileNotFoundError(f"File {path} not found") + if not filename: + filename = os.path.basename(path) + return cls(path=path, filename=filename) + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> 'Payload': + return cls.from_string(json.dumps(data)) diff --git a/templates/python/package/services/service.py.twig b/templates/python/package/services/service.py.twig index 4f617e437..d125796ec 100644 --- a/templates/python/package/services/service.py.twig +++ b/templates/python/package/services/service.py.twig @@ -5,18 +5,20 @@ class {{ service.name | caseUcfirst }}(Service): def __init__(self, client): super({{ service.name | caseUcfirst }}, self).__init__(client) -{% for method in service.methods %} + {%~ for method in service.methods %} def {{ method.name | caseSnake }}(self{% if method.parameters.all|length > 0 %}, {% endif %}{% for parameter in method.parameters.all %}{{ parameter.name | escapeKeyword | caseSnake }}{% if not parameter.required %} = None{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, on_progress = None{% endif %}): -{% if method.title %} + {%~ if method.title %} """{{ method.title }}""" -{% endif %} + {%- endif %} api_path = '{{ method.path }}' {{ include('python/base/params.twig') }} -{% if 'multipart/form-data' in method.consumes %} + {%~ if 'multipart/form-data' in method.consumes and method.type == 'upload' %} {{ include('python/base/requests/file.twig') }} -{% else %} + {%~ else %} {{ include('python/base/requests/api.twig') }} -{% endif %} -{% endfor %} + {%- endif %} + + + {%~ endfor %} diff --git a/templates/react-native/package.json.twig b/templates/react-native/package.json.twig index 685663876..1a088479f 100644 --- a/templates/react-native/package.json.twig +++ b/templates/react-native/package.json.twig @@ -26,7 +26,6 @@ }, "devDependencies": { "@rollup/plugin-typescript": "8.3.2", - "playwright": "1.15.0", "rollup": "2.75.4", "serve-handler": "6.1.0", "tslib": "2.4.0", @@ -34,6 +33,7 @@ }, "dependencies": { "expo-file-system": "16.0.8", + "parse-multipart-data": "^1.5.0", "react-native": "^0.73.6" }, "peerDependencies": { diff --git a/templates/react-native/src/client.ts.twig b/templates/react-native/src/client.ts.twig index 3e0bd8eb7..cae8a892a 100644 --- a/templates/react-native/src/client.ts.twig +++ b/templates/react-native/src/client.ts.twig @@ -1,8 +1,11 @@ -import { Models } from './models'; -import { Service } from './service'; import { Platform } from 'react-native'; +import { getBoundary, parse as parseMultipart} from './multipart'; +import { Service } from './service'; +import { Payload } from './payload'; +import { Models } from './models'; + -type Payload = { +type Params = { [key: string]: any; } @@ -345,7 +348,7 @@ class Client { } } - async call(method: string, url: URL, headers: Headers = {}, params: Payload = {}): Promise { + async call(method: string, url: URL, headers: Headers = {}, params: Params = {}): Promise { method = method.toUpperCase(); headers = Object.assign({}, this.headers, headers); @@ -397,6 +400,36 @@ class Client { if (response.headers.get('content-type')?.includes('application/json')) { data = await response.json(); + } else if (response.headers.get('content-type')?.includes('multipart/form-data')) { + const boundary = getBoundary( + response.headers.get("content-type") || "" + ); + + const body = new Uint8Array(await response.arrayBuffer()); + const parts = parseMultipart(body, boundary); + const partsObject: { [key: string]: any } = {}; + + for (const part of parts) { + if (!part.name) { + continue; + } + if (part.name === "responseBody") { + partsObject[part.name] = Payload.fromBinary(part.data, part.filename); + } else if (part.name === "responseStatusCode") { + partsObject[part.name] = parseInt(part.data.toString()); + } else if (part.name === "duration") { + partsObject[part.name] = parseFloat(part.data.toString()); + } else if (part.type === 'application/json') { + try { + partsObject[part.name] = JSON.parse(part.data.toString()); + } catch (e) { + throw new Error(`Error parsing JSON for part ${part.name}: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + } else { + partsObject[part.name] = part.data.toString(); + } + } + data = partsObject; } else { data = { message: await response.text() @@ -425,4 +458,4 @@ class Client { } export { Client, {{spec.title | caseUcfirst}}Exception }; -export type { Models, Payload }; +export type { Models, Params }; diff --git a/templates/react-native/src/index.ts.twig b/templates/react-native/src/index.ts.twig index 2a31f330c..53b2a0c71 100644 --- a/templates/react-native/src/index.ts.twig +++ b/templates/react-native/src/index.ts.twig @@ -2,12 +2,13 @@ export { Client, {{spec.title | caseUcfirst}}Exception } from './client'; {% for service in spec.services %} export { {{service.name | caseUcfirst}} } from './services/{{service.name | caseDash}}'; {% endfor %} -export type { Models, Payload, RealtimeResponseEvent, UploadProgress } from './client'; +export type { Models, Params, RealtimeResponseEvent, UploadProgress } from './client'; export type { QueryTypes, QueryTypesList } from './query'; export { Query } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; +export { Payload } from './payload'; {% for enum in spec.enums %} export { {{ enum.name | caseUcfirst }} } from './enums/{{enum.name | caseDash}}'; {% endfor %} \ No newline at end of file diff --git a/templates/react-native/src/models.ts.twig b/templates/react-native/src/models.ts.twig index 4b0f63f8b..c933904b7 100644 --- a/templates/react-native/src/models.ts.twig +++ b/templates/react-native/src/models.ts.twig @@ -1,3 +1,5 @@ +import { Payload } from './payload'; + export namespace Models { {% for definition in spec.definitions %} /** diff --git a/templates/react-native/src/multipart.ts.twig b/templates/react-native/src/multipart.ts.twig new file mode 100644 index 000000000..21ec6e319 --- /dev/null +++ b/templates/react-native/src/multipart.ts.twig @@ -0,0 +1,214 @@ +/** + * Port of: https://github.com/nachomazzara/parse-multipart-data/blob/master/src/multipart.ts + * Includes few changes for Deno compatibility. Textdiff should show the changes. + * Copied from master with commit 56052e860bc4e3fa7fe4763f69e88ec79b295a3c + * + * + * Multipart Parser (Finite State Machine) + * usage: + * const multipart = require('./multipart.js'); + * const body = multipart.DemoData(); // raw body + * const body = Buffer.from(event['body-json'].toString(),'base64'); // AWS case + * const boundary = multipart.getBoundary(event.params.header['content-type']); + * const parts = multipart.Parse(body,boundary); + * each part is: + * { filename: 'A.txt', type: 'text/plain', data: } + * or { name: 'key', data: } + */ + +type Part = { + contentDispositionHeader: string; + contentTypeHeader: string; + part: number[]; +}; + +type Input = { + filename?: string; + name?: string; + type: string; + data: Uint8Array; +}; + +enum ParsingState { + INIT, + READING_HEADERS, + READING_DATA, + READING_PART_SEPARATOR, +} + +export function parse( + multipartBodyBuffer: Uint8Array, + boundary: string +): Input[] { + let lastline = ""; + let contentDispositionHeader = ""; + let contentTypeHeader = ""; + let state: ParsingState = ParsingState.INIT; + let buffer: number[] = []; + const allParts: Input[] = []; + + let currentPartHeaders: string[] = []; + + for (let i = 0; i < multipartBodyBuffer.length; i++) { + const oneByte: number = multipartBodyBuffer[i]; + const prevByte: number | null = i > 0 ? multipartBodyBuffer[i - 1] : null; + // 0x0a => \n + // 0x0d => \r + const newLineDetected: boolean = oneByte === 0x0a && prevByte === 0x0d; + const newLineChar: boolean = oneByte === 0x0a || oneByte === 0x0d; + + if (!newLineChar) lastline += String.fromCharCode(oneByte); + if (ParsingState.INIT === state && newLineDetected) { + // searching for boundary + if ("--" + boundary === lastline) { + state = ParsingState.READING_HEADERS; // found boundary. start reading headers + } + lastline = ""; + } else if (ParsingState.READING_HEADERS === state && newLineDetected) { + // parsing headers. Headers are separated by an empty line from the content. Stop reading headers when the line is empty + if (lastline.length) { + currentPartHeaders.push(lastline); + } else { + // found empty line. search for the headers we want and set the values + for (const h of currentPartHeaders) { + if (h.toLowerCase().startsWith("content-disposition:")) { + contentDispositionHeader = h; + } else if (h.toLowerCase().startsWith("content-type:")) { + contentTypeHeader = h; + } + } + state = ParsingState.READING_DATA; + buffer = []; + } + lastline = ""; + } else if (ParsingState.READING_DATA === state) { + // parsing data + if (lastline.length > boundary.length + 4) { + lastline = ""; // mem save + } + if ("--" + boundary === lastline) { + const j = buffer.length - lastline.length; + const part = buffer.slice(0, j - 1); + + allParts.push( + process({ contentDispositionHeader, contentTypeHeader, part }) + ); + buffer = []; + currentPartHeaders = []; + lastline = ""; + state = ParsingState.READING_PART_SEPARATOR; + contentDispositionHeader = ""; + contentTypeHeader = ""; + } else { + buffer.push(oneByte); + } + if (newLineDetected) { + lastline = ""; + } + } else if (ParsingState.READING_PART_SEPARATOR === state) { + if (newLineDetected) { + state = ParsingState.READING_HEADERS; + } + } + } + return allParts; +} + +// read the boundary from the content-type header sent by the http client +// this value may be similar to: +// 'multipart/form-data; boundary=----WebKitFormBoundaryvm5A9tzU1ONaGP5B', +export function getBoundary(header: string): string { + const items = header.split(";"); + if (items) { + for (let i = 0; i < items.length; i++) { + const item = new String(items[i]).trim(); + if (item.indexOf("boundary") >= 0) { + const k = item.split("="); + return new String(k[1]).trim().replace(/^["']|["']$/g, ""); + } + } + } + return ""; +} + +export function DemoData(): { body: Uint8Array; boundary: string } { + let body = "trash1\r\n"; + body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n"; + body += "Content-Type: text/plain\r\n"; + body += + 'Content-Disposition: form-data; name="uploads[]"; filename="A.txt"\r\n'; + body += "\r\n"; + body += "@11X"; + body += "111Y\r\n"; + body += "111Z\rCCCC\nCCCC\r\nCCCCC@\r\n\r\n"; + body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n"; + body += "Content-Type: text/plain\r\n"; + body += + 'Content-Disposition: form-data; name="uploads[]"; filename="B.txt"\r\n'; + body += "\r\n"; + body += "@22X"; + body += "222Y\r\n"; + body += "222Z\r222W\n2220\r\n666@\r\n"; + body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp\r\n"; + body += 'Content-Disposition: form-data; name="input1"\r\n'; + body += "\r\n"; + body += "value1\r\n"; + body += "------WebKitFormBoundaryvef1fLxmoUdYZWXp--\r\n"; + + return { + body: new TextEncoder().encode(body), + boundary: "----WebKitFormBoundaryvef1fLxmoUdYZWXp", + }; +} + +function process(part: Part): Input { + // will transform this object: + // { header: 'Content-Disposition: form-data; name="uploads[]"; filename="A.txt"', + // info: 'Content-Type: text/plain', + // part: 'AAAABBBB' } + // into this one: + // { filename: 'A.txt', type: 'text/plain', data: } + const obj = function (str: string) { + const k = str.split("="); + const a = k[0].trim(); + + const b = JSON.parse(k[1].trim()); + const o = {}; + Object.defineProperty(o, a, { + value: b, + writable: true, + enumerable: true, + configurable: true, + }); + return o; + }; + const header = part.contentDispositionHeader.split(";"); + + const filenameData = header[2]; + let input = {}; + if (filenameData) { + input = obj(filenameData); + const contentType = part.contentTypeHeader.split(":")[1].trim(); + Object.defineProperty(input, "type", { + value: contentType, + writable: true, + enumerable: true, + configurable: true, + }); + } + // always process the name field + Object.defineProperty(input, "name", { + value: header[1].split("=")[1].replace(/"/g, ""), + writable: true, + enumerable: true, + configurable: true, + }); + + Object.defineProperty(input, "data", { + value: new Uint8Array(part.part), + writable: true, + enumerable: true, + configurable: true, + }); + return input as Input; +} diff --git a/templates/react-native/src/payload.ts.twig b/templates/react-native/src/payload.ts.twig new file mode 100644 index 000000000..facd1d3b4 --- /dev/null +++ b/templates/react-native/src/payload.ts.twig @@ -0,0 +1,82 @@ +interface ReactNativeFileObject { + uri: string; + type?: string; + name?: string; +} + +export class Payload { + public uri: string; + public size: number; + public filename?: string; + public type?: string; + + constructor(uri: string, filename?: string, type?: string, size?: number) { + this.uri = uri; + this.filename = filename; + this.type = type; + + if (size === undefined) { + const base64Data = uri.split(',')[1]; + const binary = atob(base64Data); + this.size = binary.length; + } else { + this.size = size; + } + } + + public toBinary(offset: number = 0, length?: number): Uint8Array { + const base64Data = this.uri.split(',')[1]; + const binary = atob(base64Data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + if (offset === 0 && length === undefined) { + return bytes; + } else if (length === undefined) { + return bytes.subarray(offset); + } else { + return bytes.subarray(offset, offset + length); + } + } + + public toFileObject(): ReactNativeFileObject { + return { + uri: this.uri, + type: this.type, + name: this.filename, + }; + } + + public toJson(): T { + return JSON.parse(this.toString()); + } + + public toString(): string { + const binary = this.toBinary(); + return new TextDecoder().decode(binary); + } + + public static fromJson(object: any, name?: string): Payload { + const jsonString = JSON.stringify(object); + const base64Data = btoa(jsonString); + const dataUri = `data:application/json;base64,${base64Data}`; + return new Payload(dataUri, name, 'application/json'); + } + + public static fromString(text: string, name?: string, type?: string): Payload { + const base64Data = btoa(text); + const dataUri = `data:${type || 'text/plain'};base64,${base64Data}`; + return new Payload(dataUri, name, type || 'text/plain'); + } + + public static fromBinary(binary: Uint8Array, name?: string, type?: string): Payload { + const base64Data = btoa(String.fromCharCode(...binary)); + const dataUri = `data:${type || 'application/octet-stream'};base64,${base64Data}`; + return new Payload(dataUri, name, type || 'application/octet-stream'); + } + + public static fromFileObject(file: ReactNativeFileObject): Payload { + return new Payload(file.uri, file.name, file.type); + } +} diff --git a/templates/react-native/src/service.ts.twig b/templates/react-native/src/service.ts.twig index fe1769929..cbc2701ba 100644 --- a/templates/react-native/src/service.ts.twig +++ b/templates/react-native/src/service.ts.twig @@ -1,5 +1,5 @@ import { Client } from './client'; -import type { Payload } from './client'; +import type { Params } from './client'; export class Service { static CHUNK_SIZE = 5*1024*1024; // 5MB @@ -10,9 +10,9 @@ export class Service { this.client = client; } - static flatten(data: Payload, prefix = ''): Payload { - let output: Payload = {}; - + static flatten(data: Params, prefix = ''): Params { + let output: Params = {}; + for (const [key, value] of Object.entries(data)) { let finalKey = prefix ? prefix + '[' + key +']' : key; if (Array.isArray(value)) { diff --git a/templates/react-native/src/services/template.ts.twig b/templates/react-native/src/services/template.ts.twig index 347ecfb73..6ede42bfe 100644 --- a/templates/react-native/src/services/template.ts.twig +++ b/templates/react-native/src/services/template.ts.twig @@ -1,26 +1,27 @@ import { Service } from '../service'; -import { {{ spec.title | caseUcfirst}}Exception, Client } from '../client'; +import { {{ spec.title | caseUcfirst }}Exception, Client } from '../client'; +import { Payload } from '../payload'; import type { Models } from '../models'; -import type { UploadProgress, Payload } from '../client'; +import type { UploadProgress, Params } from '../client'; import * as FileSystem from 'expo-file-system'; import { Platform } from 'react-native'; {% set added = [] %} -{% for method in service.methods %} -{% for parameter in method.parameters.all %} -{% if parameter.enumValues is not empty %} -{% if parameter.enumName is not empty %} -{% set name = parameter.enumName %} -{% else %} -{% set name = parameter.name %} -{% endif %} -{% if name not in added %} +{%~ for method in service.methods %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.enumValues is not empty %} + {%~ if parameter.enumName is not empty %} + {% set name = parameter.enumName %} + {% else %} + {% set name = parameter.name %} + {%- endif %} + {%~ if name not in added -%} import { {{ name | caseUcfirst }} } from '../enums/{{ name | caseDash }}'; -{% set added = added|merge([name]) %} -{% endif %} -{% endif %} -{% endfor %} -{% endfor %} + {%~ set added = added|merge([name]) -%} + {%- endif %} + {%- endif %} + {%- endfor %} +{%- endfor %} export class {{ service.name | caseUcfirst }} extends Service { @@ -28,96 +29,88 @@ export class {{ service.name | caseUcfirst }} extends Service { { super(client); } -{% for method in service.methods %} + {%~ for method in service.methods %} /** * {{ method.title }} * -{% if method.description %} -{{ method.description|comment2 }} -{% endif %} + {%~ if method.description %} + * {{ method.description }} + {%~ endif %} * -{% for parameter in method.parameters.all %} + {%~ for parameter in method.parameters.all %} * @param {{ '{' }}{{ parameter | getPropertyType(method) | raw }}{{ '}' }} {{ parameter.name | caseCamel | escapeKeyword }} -{% endfor %} - * @throws {{ '{' }}{{ spec.title | caseUcfirst}}Exception} + {%~ endfor %} + * @throws {{ '{' }}{{ spec.title | caseUcfirst }}Exception} * @returns {% if method.type == 'webAuth' %}{void|string}{% elseif method.type == 'location' %}{URL}{% else %}{Promise}{% endif %} */ - {% if method.type != 'location' and method.type != 'webAuth'%}async {% endif %}{{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}({% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress = (progress: UploadProgress) => {}{% endif %}): {{ method | getReturn(spec) | raw }} { -{% for parameter in method.parameters.all %} -{% if parameter.required %} + {% if method.type != 'location' and method.type != 'webAuth'%}async {% endif %}{{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}({%~ for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{%~ if not parameter.required or parameter.nullable %}?{%- endif %}: {{ parameter | getPropertyType(method) | raw }}{%~ if not loop.last %}, {%- endif %}{%- endfor %}{%~ if 'multipart/form-data' in method.consumes %}, onProgress = (progress: UploadProgress) => {}{%- endif %}): {{ method | getReturn(spec) | raw }} { + {%~ for parameter in method.parameters.all %} + {%~ if parameter.required %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} === 'undefined') { throw new {{spec.title | caseUcfirst}}Exception('Missing required parameter: "{{ parameter.name | caseCamel | escapeKeyword }}"'); } -{% endif %} -{% endfor %} - const apiPath = '{{ method.path }}'{% for parameter in method.parameters.path %}.replace('{{ '{' }}{{ parameter.name | caseCamel | escapeKeyword }}{{ '}' }}', {{ parameter.name | caseCamel | escapeKeyword }}){% endfor %}; - const payload: Payload = {}; + {%~ endif %} + {%~ endfor %} + const apiPath = '{{ method.path }}'{%~ for parameter in method.parameters.path %}.replace('{{ '{' }}{{ parameter.name | caseCamel | escapeKeyword }}{{ '}' }}', {{ parameter.name | caseCamel | escapeKeyword }}){%- endfor %}; + const params: Params = {}; -{% for parameter in method.parameters.query %} + {%~ for parameter in method.parameters.query %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} !== 'undefined') { - payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; + params['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; } -{% endfor %} -{% for parameter in method.parameters.body %} + {%~ endfor %} + {%~ for parameter in method.parameters.body %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} !== 'undefined') { - payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; + params['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; } -{% endfor %} + {%~ endfor %} const uri = new URL(this.client.config.endpoint + apiPath); -{% if method.type == 'location' or method.type == 'webAuth' %} -{% if method.auth|length > 0 %} -{% for node in method.auth %} -{% for key,header in node|keys %} - payload['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; -{% endfor %} -{% endfor %} -{% endif %} + {%~ if method.type == 'location' or method.type == 'webAuth' %} + {%~ if method.auth|length > 0 %} + {%~ for node in method.auth %} + {%~ for key,header in node|keys %} + params['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; + {%~ endfor %} + {%~ endfor %} + {%~ endif %} - for (const [key, value] of Object.entries(Service.flatten(payload))) { + for (const [key, value] of Object.entries(Service.flatten(params))) { uri.searchParams.append(key, value); } -{% endif %} -{% if method.type == 'webAuth' %} + {%~ endif %} + {%~ if method.type == 'webAuth' or method.type == 'location' %} return uri; -{% elseif method.type == 'location' %} - return uri; -{% else %} -{% if 'multipart/form-data' in method.consumes %} -{% for parameter in method.parameters.all %} -{% if parameter.type == 'file' %} + {%~ else %} + const apiHeaders: { [header: string]: string } = { + {%~ for parameter in method.parameters.header %} + '{{ parameter.name | caseCamel | escapeKeyword }}': this.client.${{ parameter.name | caseCamel | escapeKeyword }}, + {%- endfor %} + {%~ for key, header in method.headers %} + '{{ key }}': '{{ header }}', + {%~ endfor %} + } + + {%~ if 'multipart/form-data' in method.consumes and method.type == 'upload' %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.type == 'file' %} const size = {{ parameter.name | caseCamel | escapeKeyword }}.size; if (size <= Service.CHUNK_SIZE) { - return await this.client.call('{{ method.method | caseLower }}', uri, { -{% for parameter in method.parameters.header %} - '{{ parameter.name | caseCamel | escapeKeyword }}': this.client.${{ parameter.name | caseCamel | escapeKeyword }}, -{% endfor %} -{% for key, header in method.headers %} - '{{ key }}': '{{ header }}', -{% endfor %} - }, payload); - } - - const apiHeaders: { [header: string]: string } = { -{% for parameter in method.parameters.header %} - '{{ parameter.name | caseCamel | escapeKeyword }}': this.client.${{ parameter.name | caseCamel | escapeKeyword }}, -{% endfor %} -{% for key, header in method.headers %} - '{{ key }}': '{{ header }}', -{% endfor %} + params['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}.toFileObject(); + return await this.client.call('{{ method.method | caseLower }}', uri, apiHeaders, params); } let offset = 0; let response = undefined; -{% for parameter in method.parameters.all %} -{% if parameter.isUploadID %} + {%~ for parameter in method.parameters.all %} + {%~ if parameter.isUploadID %} if({{ parameter.name | caseCamel | escapeKeyword }} != 'unique()') { try { response = await this.client.call('GET', new URL(this.client.config.endpoint + apiPath + '/' + {{ parameter.name }}), apiHeaders); @@ -125,8 +118,8 @@ export class {{ service.name | caseUcfirst }} extends Service { } catch(e) { } } -{% endif %} -{% endfor %} + {%~ endif %} + {%~ endfor %} let timestamp = new Date().getTime(); while (offset < size) { @@ -137,20 +130,22 @@ export class {{ service.name | caseUcfirst }} extends Service { apiHeaders['x-{{spec.title | caseLower }}-id'] = response.$id; } - let chunk = await FileSystem.readAsStringAsync({{ parameter.name | caseCamel | escapeKeyword }}.uri, { - encoding: FileSystem.EncodingType.Base64, - position: offset, - length: Service.CHUNK_SIZE - }); - var path = `data:${{'{'}}{{ parameter.name | caseCamel | escapeKeyword }}.type{{'}'}};base64,${{'{'}}chunk{{'}'}}`; + let chunkBuffer = {{ parameter.name | caseCamel | escapeKeyword }}.toBinary(offset, end - offset + 1); + let chunk = btoa(String.fromCharCode(...chunkBuffer)); + + var path = `data:${{ parameter.name | caseCamel | escapeKeyword }}.type};base64,${chunk}`; if (Platform.OS.toLowerCase() === 'android') { path = FileSystem.cacheDirectory + '/tmp_chunk_' + timestamp; await FileSystem.writeAsStringAsync(path, chunk, {encoding: FileSystem.EncodingType.Base64}); } - payload['{{ parameter.name }}'] = {{ '{' }} uri: path, name: {{ parameter.name | caseCamel | escapeKeyword }}.name, type: {{ parameter.name | caseCamel | escapeKeyword }}.type {{ '}' }}; + params['{{ parameter.name }}'] = { + uri: path, + name: {{ parameter.name | caseCamel | escapeKeyword }}.filename, + type: {{ parameter.name | caseCamel | escapeKeyword }}.type + }; - response = await this.client.call('{{ method.method | caseLower }}', uri, apiHeaders, payload); + response = await this.client.call('{{ method.method | caseLower }}', uri, apiHeaders, params); if (onProgress) { onProgress({ @@ -164,19 +159,12 @@ export class {{ service.name | caseUcfirst }} extends Service { offset += Service.CHUNK_SIZE; } return response; -{% endif %} -{% endfor %} -{% else %} - return await this.client.call('{{ method.method | caseLower }}', uri, { -{% for parameter in method.parameters.header %} - '{{ parameter.name | caseCamel | escapeKeyword }}': this.client.${{ parameter.name | caseCamel | escapeKeyword }}, -{% endfor %} -{% for key, header in method.headers %} - '{{ key }}': '{{ header }}', -{% endfor %} - }, payload); -{% endif %} -{% endif %} + {%~ endif %} + {%~ endfor %} + {%~ else %} + return await this.client.call('{{ method.method | caseLower }}', uri, apiHeaders, params); + {%~ endif %} + {%~ endif %} } -{% endfor %} + {%~ endfor %} }; diff --git a/templates/react-native/tsconfig.json.twig b/templates/react-native/tsconfig.json.twig index 8a27d1f04..d469a2fda 100644 --- a/templates/react-native/tsconfig.json.twig +++ b/templates/react-native/tsconfig.json.twig @@ -1,22 +1,33 @@ { "compilerOptions": { + "target": "esnext", + "module": "esnext", + "types": ["react-native"], + "lib": [ + "dom", + "es2019", + "es2020.bigint", + "es2020.date", + "es2020.number", + "es2020.promise", + "es2020.string", + "es2020.symbol.wellknown", + "es2021.promise", + "es2021.string", + "es2021.weakref", + "es2022.array", + "es2022.object", + "es2022.string" + ], "allowJs": true, + "jsx": "react-native", + "isolatedModules": true, + "strict": true, + "moduleResolution": "node", + "resolveJsonModule": true, "allowSyntheticDefaultImports": true, - "baseUrl": "src", - "declaration": false, "esModuleInterop": true, - "inlineSourceMap": false, - "lib": ["ESNext", "DOM"], - "listEmittedFiles": false, - "listFiles": false, - "moduleResolution": "node", - "noFallthroughCasesInSwitch": true, - "pretty": true, - "rootDir": "src", - "skipLibCheck": true, - "strict": true, - "target": "ES6", - "traceResolution": false, + "skipLibCheck": true }, "compileOnSave": false, "exclude": ["node_modules", "dist"], diff --git a/templates/ruby/Gemfile.twig b/templates/ruby/Gemfile.twig index cd8aa9e04..9d5f1d762 100644 --- a/templates/ruby/Gemfile.twig +++ b/templates/ruby/Gemfile.twig @@ -1,3 +1,6 @@ source 'https://rubygems.org' -gemspec \ No newline at end of file +gem 'mime-types', '~> 3.4.1' + +gemspec + diff --git a/templates/ruby/base/params.twig b/templates/ruby/base/params.twig index 8276ff6d4..a689856ae 100644 --- a/templates/ruby/base/params.twig +++ b/templates/ruby/base/params.twig @@ -12,9 +12,9 @@ {% endif %} {% endfor %} api_params = { -{% for parameter in method.parameters.query | merge(method.parameters.body) %} - {{ parameter.name }}: {{ parameter.name | caseSnake | escapeKeyword }}, -{% endfor %} + {%~ for parameter in method.parameters.query | merge(method.parameters.body) %} + {{ parameter.name }}: {{ parameter.name | caseSnake | escapeKeyword }}, + {%~ endfor %} } api_headers = { diff --git a/templates/ruby/lib/container.rb.twig b/templates/ruby/lib/container.rb.twig index fd83f4b57..0bc5ba509 100644 --- a/templates/ruby/lib/container.rb.twig +++ b/templates/ruby/lib/container.rb.twig @@ -6,7 +6,8 @@ require 'mime/types' require_relative '{{ spec.title | caseSnake }}/client' require_relative '{{ spec.title | caseSnake }}/service' require_relative '{{ spec.title | caseSnake }}/exception' -require_relative '{{ spec.title | caseSnake }}/input_file' +require_relative '{{ spec.title | caseSnake }}/payload' +require_relative '{{ spec.title | caseSnake }}/multipart' require_relative '{{ spec.title | caseSnake }}/query' require_relative '{{ spec.title | caseSnake }}/permission' require_relative '{{ spec.title | caseSnake }}/role' diff --git a/templates/ruby/lib/container/client.rb.twig b/templates/ruby/lib/container/client.rb.twig index 0eb2f86fb..10369116c 100644 --- a/templates/ruby/lib/container/client.rb.twig +++ b/templates/ruby/lib/container/client.rb.twig @@ -16,22 +16,20 @@ module {{ spec.title | caseUcfirst }} 'x-sdk-platform'=> '{{ sdk.platform }}', 'x-sdk-language'=> '{{ language.name | caseLower }}', 'x-sdk-version'=> '{{ sdk.version }}'{% if spec.global.defaultHeaders | length > 0 %},{% endif %} - -{% for key,header in spec.global.defaultHeaders %} - '{{key}}' => '{{header}}'{% if not loop.last %},{% endif %} -{% endfor %} - + {%~ for key, header in spec.global.defaultHeaders ~%} + '{{key | caseLower }}' => '{{header}}'{% if not loop.last %},{% endif %} + {%~ endfor ~%} } @endpoint = '{{spec.endpoint}}' end -{% for header in spec.global.headers %} + {%~ for header in spec.global.headers %} # Set {{header.key | caseUcfirst}} # -{% if header.description %} + {%~ if header.description %} # {{header.description}} # -{% endif %} + {%- endif %} # @param [String] value The value to set for the {{ header.key }} header # # @return [self] @@ -41,7 +39,7 @@ module {{ spec.title | caseUcfirst }} self end -{% endfor %} + {%~ endfor %} # Set endpoint. # # @param [String] endpoint The endpoint to set @@ -64,7 +62,6 @@ module {{ spec.title | caseUcfirst }} self end - # Add Header # # @param [String] key The key for the header to add @@ -107,20 +104,9 @@ module {{ spec.title | caseUcfirst }} on_progress: nil, response_type: nil ) - input_file = params[param_name.to_sym] - - case input_file.source_type - when 'path' - size = ::File.size(input_file.path) - when 'string' - size = input_file.data.bytesize - end + payload = params[param_name.to_sym] - if size < @chunk_size - if input_file.source_type == 'path' - input_file.data = IO.read(input_file.path) - end - params[param_name.to_sym] = input_file + if payload.size < @chunk_size return call( method: 'POST', path: path, @@ -144,21 +130,13 @@ module {{ spec.title | caseUcfirst }} offset = chunks_uploaded * @chunk_size end - while offset < size - case input_file.source_type - when 'path' - string = IO.read(input_file.path, @chunk_size, offset) - when 'string' - string = input_file.data.byteslice(offset, [@chunk_size, size - offset].min) - end - - params[param_name.to_sym] = InputFile::from_string( - string, - filename: input_file.filename, - mime_type: input_file.mime_type + while offset < payload.size + params[param_name.to_sym] = Payload.from_binary( + payload.to_binary(offset, [@chunk_size, payload.size - offset].min), + filename: payload.filename ) - headers['content-range'] = "bytes #{offset}-#{[offset + @chunk_size - 1, size - 1].min}/#{size}" + headers['content-range'] = "bytes #{offset}-#{[offset + @chunk_size - 1, payload.size - 1].min}/#{payload.size}" result = call( method: 'POST', @@ -175,8 +153,8 @@ module {{ spec.title | caseUcfirst }} on_progress.call({ id: result['$id'], - progress: ([offset, size].min).to_f/size.to_f * 100.0, - size_uploaded: [offset, size].min, + progress: ([offset, payload.size].min).to_f/payload.size.to_f * 100.0, + size_uploaded: [offset, payload.size].min, chunks_total: result['chunksTotal'], chunks_uploaded: result['chunksUploaded'] }) unless on_progress.nil? @@ -203,18 +181,27 @@ module {{ spec.title | caseUcfirst }} @http.use_ssl = !@self_signed payload = '' - headers = @headers.merge(headers) + headers = @headers.merge(headers.transform_keys(&:to_s)) params.compact! - @boundary = "----A30#3ad1" if method != "GET" - case headers[:'content-type'] + case headers['content-type'] when 'application/json' payload = params.to_json when 'multipart/form-data' - payload = encode_form_data(params) + "--#{@boundary}--\r\n" - headers[:'content-type'] = "multipart/form-data; boundary=#{@boundary}" + multipart = MultipartBuilder.new() + + params.each do |name, value| + if value.is_a?(Payload) + multipart.add(name, value.to_s, filename: value.filename) + else + multipart.add(name, value) + end + end + + headers['content-type'] = multipart.content_type + payload = multipart.body else payload = encode(params) end @@ -244,7 +231,7 @@ module {{ spec.title | caseUcfirst }} return fetch(method, uri, headers, {}, response_type, limit - 1) end - if response.content_type == 'application/json' + if response.content_type.start_with?('application/json') begin result = JSON.parse(response.body) rescue JSON::ParserError => e @@ -255,48 +242,33 @@ module {{ spec.title | caseUcfirst }} raise {{spec.title | caseUcfirst}}::Exception.new(result['message'], result['status'], result['type'], result) end - unless response_type.respond_to?("from") - return result + if response_type.respond_to?("from") + return response_type.from(map: result) end - return response_type.from(map: result) + return result end if response.code.to_i >= 400 raise {{spec.title | caseUcfirst}}::Exception.new(response.body, response.code, response) end - if response.respond_to?("body_permitted?") - return response.body if response.body_permitted? - end + if response.content_type.start_with?('multipart/form-data') + multipart = MultipartParser.new(response.body, response['content-type']) + result = multipart.to_hash - return response - end - - def encode_form_data(value, key=nil) - case value - when Hash - value.map { |k,v| encode_form_data(v,k) }.join - when Array - value.map { |v| encode_form_data(v, "#{key}[]") }.join - when nil - '' - else - post_body = [] - if value.instance_of? InputFile - post_body << "--#{@boundary}" - post_body << "Content-Disposition: form-data; name=\"#{key}\"; filename=\"#{value.filename}\"" - post_body << "Content-Type: #{value.mime_type}" - post_body << "" - post_body << value.data - else - post_body << "--#{@boundary}" - post_body << "Content-Disposition: form-data; name=\"#{key}\"" - post_body << "" - post_body << value.to_s + if response_type.respond_to?("from") + return response_type.from(map: result) end - post_body.join("\r\n") + "\r\n" + + return result end + + if response.class.body_permitted? + return response.body + end + + return response end def encode(value, key = nil) diff --git a/templates/ruby/lib/container/input_file.rb.twig b/templates/ruby/lib/container/input_file.rb.twig deleted file mode 100644 index 2d8afe802..000000000 --- a/templates/ruby/lib/container/input_file.rb.twig +++ /dev/null @@ -1,33 +0,0 @@ -require 'mime/types' - -module {{spec.title | caseUcfirst}} - class InputFile - attr_accessor :path - attr_accessor :filename - attr_accessor :mime_type - attr_accessor :source_type - attr_accessor :data - - def self.from_path(path) - instance = InputFile.new - instance.path = path - instance.filename = ::File.basename(path) - instance.mime_type = MIME::Types.type_for(path).first.content_type - instance.source_type = 'path' - instance - end - - def self.from_string(string, filename: nil, mime_type: nil) - instance = InputFile.new - instance.data = string - instance.filename = filename - instance.mime_type = mime_type - instance.source_type = 'string' - instance - end - - def self.from_bytes(bytes, filename: nil, mime_type: nil) - self.from_string(bytes.pack('C*'), filename: filename, mime_type: mime_type) - end - end -end \ No newline at end of file diff --git a/templates/ruby/lib/container/models/model.rb.twig b/templates/ruby/lib/container/models/model.rb.twig index 83d5a22d4..1c1bef06b 100644 --- a/templates/ruby/lib/container/models/model.rb.twig +++ b/templates/ruby/lib/container/models/model.rb.twig @@ -4,71 +4,71 @@ module {{ spec.title | caseUcfirst }} module Models class {{ definition.name | caseUcfirst }} -{% for property in definition.properties %} + {%~ for property in definition.properties %} attr_reader :{{ property.name | caseSnake | escapeKeyword }} -{% endfor %} -{% if definition.additionalProperties %} + {%~ endfor %} + {%~ if definition.additionalProperties %} attr_reader :data -{% endif %} + {%- endif %} def initialize( -{% for property in definition.properties %} + {%~ for property in definition.properties %} {{ property.name | caseSnake | escapeKeyword }}:{% if not property.required %} {{ property.default }}{% endif %}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} -{% endfor %} -{% if definition.additionalProperties %} + {%~ endfor %} + {%~ if definition.additionalProperties %} data: -{% endif %} + {%~ endif %} ) -{% for property in definition.properties %} + {%~ for property in definition.properties %} @{{ property.name | caseSnake | escapeKeyword }} = {{ property.name | caseSnake | escapeKeyword }} -{% endfor %} -{% if definition.additionalProperties %} + {%~ endfor %} + {%~ if definition.additionalProperties %} @data = data -{% endif %} + {%~ endif %} end def self.from(map:) {{ definition.name | caseUcfirst }}.new( -{% for property in definition.properties %} + {%~ for property in definition.properties %} {{ property.name | caseSnake | escapeKeyword | removeDollarSign }}: {% if property.sub_schema %}{% if property.type == 'array' %}map["{{ property.name }}"].map { |it| {{ property.sub_schema | caseUcfirst }}.from(map: it) }{% else %}{{property.sub_schema | caseUcfirst}}.from(map: map["{{property.name }}"]){% endif %}{% else %}map["{{ property.name }}"]{% endif %}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} -{% endfor %} -{% if definition.additionalProperties %} + {%~ endfor %} + {%~ if definition.additionalProperties %} data: map -{% endif %} + {%~ endif %} ) end def to_map { -{% for property in definition.properties %} + {%~ for property in definition.properties %} "{{ property.name }}": {% if property.sub_schema %}{% if property.type == 'array' %}@{{ property.name | caseSnake | escapeKeyword | removeDollarSign }}.map { |it| it.to_map }{% else %}@{{property.name | caseSnake | escapeKeyword | removeDollarSign }}.to_map{% endif %}{% else %}@{{property.name | caseSnake | escapeKeyword }}{% endif %}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} -{% endfor %} -{% if definition.additionalProperties %} + {%~ endfor %} + {%~ if definition.additionalProperties %} "data": @data -{% endif %} + {%~ endif %} } end -{% if definition.additionalProperties %} + {%~ if definition.additionalProperties %} def convert_to(from_json) from_json.call(data) end -{% endif %} -{% for property in definition.properties %} -{% if property.sub_schema %} -{% for def in spec.definitions %} -{% if def.name == property.sub_schema and def.additionalProperties and property.type == 'array' %} + {%~ endif %} + {%~ for property in definition.properties %} + {%~ if property.sub_schema %} + {%~ for def in spec.definitions %} + {%~ if def.name == property.sub_schema and def.additionalProperties and property.type == 'array' %} def convert_to(from_json) {{ property.name | caseSnake | escapeKeyword }}.map { |it| it.convert_to(from_json) } end -{% endif %} -{% endfor %} -{% endif %} -{% endfor %} + {%- endif %} + {%- endfor %} + {%- endif %} + {%- endfor %} end end end diff --git a/templates/ruby/lib/container/multipart.rb.twig b/templates/ruby/lib/container/multipart.rb.twig new file mode 100644 index 000000000..53f1deed8 --- /dev/null +++ b/templates/ruby/lib/container/multipart.rb.twig @@ -0,0 +1,127 @@ +require 'mime/types' + +module Appwrite + class MultipartBuilder + attr_reader :boundary + + def initialize(boundary: nil) + @boundary = boundary ||= "----RubyMultipartPost#{rand(1000000)}" + @parts = [] + end + + def add(name, contents, filename: nil, content_type: nil) + if contents.is_a?(Array) + contents.each_with_index do |element, index| + add("#{name}[#{index}]", element) + end + return + end + + part = "--#{@boundary}\r\n" + part << "Content-Disposition: form-data; name=\"#{name}\"" + part << "; filename=\"#{filename}\"" if filename + part << "\r\n" + if content_type + part << "Content-Type: #{content_type}\r\n" + elsif filename + content_type = MIME::Types.type_for(filename).first&.content_type || 'application/octet-stream' + part << "Content-Type: #{content_type}\r\n" + end + part << "\r\n" + part << contents.to_s + part << "\r\n" + + @parts << part + end + + def body + @parts.join + "--#{@boundary}--\r\n" + end + + def content_type + "multipart/form-data; boundary=#{@boundary}" + end + end + + class MultipartParser + attr_reader :parts + + def initialize(multipart_string, content_type) + @multipart_string = multipart_string + @boundary = _extract_boundary(content_type) + @parts = {} + parse + end + + def _extract_boundary(content_type) + match = content_type.match(/boundary="?(.+?)"?(?:\s*;|$)/) + if match + return match[1] + end + + puts content_type + + raise "Boundary not found in Content-Type header" + end + + def parse + # Split the multipart string into individual parts + parts = @multipart_string.split("--#{@boundary}") + + # Remove the first (empty) and last (boundary end) elements + parts = parts[1...-1] + + parts.each do |part| + # Split headers and content + headers, content = part.strip.split("\r\n\r\n", 2) + + # Parse headers + headers_hash = headers.split("\r\n").each_with_object({}) do |header, hash| + key, value = header.split(": ", 2) + hash[key.downcase] = value + end + + # Extract name from Content-Disposition header + content_disposition = headers_hash["content-disposition"] || "" + name = content_disposition[/name="([^"]*)"/, 1] + + # If no name is found, use a default naming scheme + name ||= "unnamed_part_#{@parts.length}" + + # Store the parsed data + @parts[name] = { + contents: content.strip, + headers: headers_hash + } + end + end + + def to_hash + h = {} + + @parts.each do |name, part| + case name + when "responseBody" + h[name] = Payload.from_binary(part[:contents]) + when "responseHeaders" + h[name] = part[:contents].split("\r\n").each_with_object({}) do |header, hash| + key, value = header.split(": ", 2) + hash[key] = value + end + when "responseStatusCode" + h[name] = part[:contents].to_i + when "duration" + h[name] = part[:contents].to_f + else + begin + h[name] = part[:contents].force_encoding("utf-8") + rescue + h[name] = part[:contents] + end + end + end + + h + end + end +end \ No newline at end of file diff --git a/templates/ruby/lib/container/payload.rb.twig b/templates/ruby/lib/container/payload.rb.twig new file mode 100644 index 000000000..ae3ab77b5 --- /dev/null +++ b/templates/ruby/lib/container/payload.rb.twig @@ -0,0 +1,94 @@ +require 'fileutils' + +module Appwrite + class Payload + attr_reader :filename + attr_reader :size + + def initialize(data, path, filename) + if data.nil? && path.nil? then + raise ArgumentError.new('Payload must have one of data or path') + end + + @path = path + @data = data + + @filename = filename + @size = if @data then + @data.bytesize + else + File.size(@path) + end + end + + # @param [Integer] offset + # @param [Integer] length + # @return [String] + def to_binary(offset = 0, length = nil) + length ||= @size - offset + if @data then + @data.byteslice(offset, length) + else + IO.read(@path, length, offset) + end + end + + # @return [String] + def to_s() + to_binary().force_encoding('UTF-8') + end + + alias to_string to_s + + # @return [Hash] + def to_json() + JSON.parse(to_s()) + end + + # @param [String] path + # @return [void] + def to_file(path) + FileUtils.mkdir_p(File.dirname(path)) + File.open(path, 'wb') { |f| f.write(to_binary()) } + end + + # @param [String] bytes + # @param [String, nil] filename + # @return [Payload] + def self.from_binary(bytes, filename: nil) + new(bytes, nil, filename) + end + + # @param [String] string + # @param [String, nil] filename + # @return [Payload] + def self.from_string(string, filename: nil) + bytes = string.encode('UTF-8') + new(bytes, nil, filename) + end + + # @param [Hash, Array] object + # @param [String, nil] filename + # @return [Payload] + def self.from_json(object, filename: nil) + if !object.is_a?(Hash) && !object.is_a?(Array) then + raise ArgumentError.new('Object must be a Hash or Array') + end + json = JSON.generate(object) + self.from_string(json, filename: filename) + end + + # @param [String] path + # @param [String, nil] filename + # @return [Payload] + def self.from_file(path, filename: nil) + raise ArgumentError.new('File not found') if !File.exists?(path) + filename = if filename.nil? then + File.basename(path) + else + filename + end + new(nil, path, filename) + end + end +end \ No newline at end of file diff --git a/templates/ruby/lib/container/services/service.rb.twig b/templates/ruby/lib/container/services/service.rb.twig index dbfc91d67..d8be8bb02 100644 --- a/templates/ruby/lib/container/services/service.rb.twig +++ b/templates/ruby/lib/container/services/service.rb.twig @@ -16,12 +16,12 @@ module {{spec.title | caseUcfirst}} # # @return [{{ method.responseModel | caseUcfirst }}] def {{ method.name | caseSnake }}({% for parameter in method.parameters.all %}{{ parameter.name | caseSnake | escapeKeyword }}:{% if not parameter.required %} nil{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, on_progress: nil{% endif %}) -{{ include('ruby/base/params.twig')}} -{% if 'multipart/form-data' in method.consumes %} -{{ include('ruby/base/requests/file.twig')}} -{% else %} -{{ include('ruby/base/requests/api.twig')}} -{% endif %} + {{~ include('ruby/base/params.twig')}} + {%~ if 'multipart/form-data' in method.consumes and method.type == "upload" %} + {{~ include('ruby/base/requests/file.twig')}} + {%~ else %} + {{~ include('ruby/base/requests/api.twig')}} + {%~ endif %} end diff --git a/templates/web/package.json.twig b/templates/web/package.json.twig index e1e520353..8477d199e 100644 --- a/templates/web/package.json.twig +++ b/templates/web/package.json.twig @@ -26,7 +26,6 @@ }, "devDependencies": { "@rollup/plugin-typescript": "8.3.2", - "playwright": "1.15.0", "rollup": "2.75.4", "serve-handler": "6.1.0", "tslib": "2.4.0", diff --git a/templates/web/src/client.ts.twig b/templates/web/src/client.ts.twig index 4d0cac6df..0cab6fa4c 100644 --- a/templates/web/src/client.ts.twig +++ b/templates/web/src/client.ts.twig @@ -1,9 +1,11 @@ import { Models } from './models'; +import { Payload } from './payload'; +import { MultipartParser } from './multipart'; /** * Payload type representing a key-value pair with string keys and any values. */ -type Payload = { +type Params = { [key: string]: any; } @@ -539,7 +541,7 @@ class Client { } } - prepareRequest(method: string, url: URL, headers: Headers = {}, params: Payload = {}): { uri: string, options: RequestInit } { + async prepareRequest(method: string, url: URL, headers: Headers = {}, params: Params = {}): Promise<{ uri: string, options: RequestInit }> { method = method.toUpperCase(); headers = Object.assign({}, this.headers, headers); @@ -570,20 +572,25 @@ class Client { case 'multipart/form-data': const formData = new FormData(); - for (const [key, value] of Object.entries(params)) { - if (value instanceof File) { - formData.append(key, value, value.name); + for (const [name, value] of Object.entries(params)) { + if (value instanceof Payload) { + if (value.filename) { + formData.append(name, await value.toFile(), value.filename); + } else { + formData.append(name, await value.toString()); + } } else if (Array.isArray(value)) { for (const nestedValue of value) { - formData.append(`${key}[]`, nestedValue); + formData.append(`${name}[]`, nestedValue); } } else { - formData.append(key, value); + formData.append(name, value); } } - + options.body = formData; delete headers['content-type']; + headers['accept'] = 'multipart/form-data'; break; } } @@ -591,35 +598,37 @@ class Client { return { uri: url.toString(), options }; } - async chunkedUpload(method: string, url: URL, headers: Headers = {}, originalPayload: Payload = {}, onProgress: (progress: UploadProgress) => void) { - const file = Object.values(originalPayload).find((value) => value instanceof File); + async chunkedUpload(method: string, url: URL, headers: Headers = {}, params: Params = {}, onProgress: (progress: UploadProgress) => void) { + const entry = Object.entries(params).find(([_key, value]) => value instanceof Payload); + if (!entry) { + throw new Error('No payload found in params'); + } + + const [paramName, payload] = entry as [string, Payload]; - if (file.size <= Client.CHUNK_SIZE) { - return await this.call(method, url, headers, originalPayload); + if (payload.size <= Client.CHUNK_SIZE) { + return await this.call(method, url, headers, params); } let start = 0; let response = null; - while (start < file.size) { - let end = start + Client.CHUNK_SIZE; // Prepare end for the next chunk - if (end >= file.size) { - end = file.size; // Adjust for the last chunk to include the last byte - } + while (start < payload.size) { + const end = Math.min(start + Client.CHUNK_SIZE, payload.size); - headers['content-range'] = `bytes ${start}-${end-1}/${file.size}`; - const chunk = file.slice(start, end); + headers['content-range'] = `bytes ${start}-${end-1}/${payload.size}`; - let payload = { ...originalPayload, file: new File([chunk], file.name)}; + const buffer = await payload.toBinary(start, end - start); + params[paramName] = Payload.fromBinary(buffer, payload.filename); - response = await this.call(method, url, headers, payload); + response = await this.call(method, url, headers, params); if (onProgress && typeof onProgress === 'function') { onProgress({ $id: response.$id, - progress: Math.round((end / file.size) * 100), + progress: Math.round((end / payload.size) * 100), sizeUploaded: end, - chunksTotal: Math.ceil(file.size / Client.CHUNK_SIZE), + chunksTotal: Math.ceil(payload.size / Client.CHUNK_SIZE), chunksUploaded: Math.ceil(end / Client.CHUNK_SIZE) }); } @@ -638,8 +647,8 @@ class Client { return this.call('GET', new URL(this.config.endpoint + '/ping')); } - async call(method: string, url: URL, headers: Headers = {}, params: Payload = {}, responseType = 'json'): Promise { - const { uri, options } = this.prepareRequest(method, url, headers, params); + async call(method: string, url: URL, headers: Headers = {}, params: Params = {}, responseType = 'json'): Promise { + const { uri, options } = await this.prepareRequest(method, url, headers, params); let data: any = null; @@ -652,6 +661,12 @@ class Client { if (response.headers.get('content-type')?.includes('application/json')) { data = await response.json(); + + } else if (response.headers.get('content-type')?.includes('multipart/form-data')) { + const buffer = await response.arrayBuffer(); + const multipart = new MultipartParser(buffer, response.headers.get('content-type')!); + data = multipart.toObject(); + } else if (responseType === 'arrayBuffer') { data = await response.arrayBuffer(); } else { @@ -674,8 +689,8 @@ class Client { return data; } - static flatten(data: Payload, prefix = ''): Payload { - let output: Payload = {}; + static flatten(data: Params, prefix = ''): Params { + let output: Params = {}; for (const [key, value] of Object.entries(data)) { let finalKey = prefix ? prefix + '[' + key +']' : key; @@ -692,6 +707,6 @@ class Client { export { Client, {{spec.title | caseUcfirst}}Exception }; export { Query } from './query'; -export type { Models, Payload, UploadProgress }; +export type { Models, Params, UploadProgress }; export type { RealtimeResponseEvent }; export type { QueryTypes, QueryTypesList } from './query'; diff --git a/templates/web/src/index.ts.twig b/templates/web/src/index.ts.twig index 79b35c1ee..83101b96e 100644 --- a/templates/web/src/index.ts.twig +++ b/templates/web/src/index.ts.twig @@ -9,8 +9,9 @@ export { Client, Query, {{spec.title | caseUcfirst}}Exception } from './client'; {% for service in spec.services %} export { {{service.name | caseUcfirst}} } from './services/{{service.name | caseDash}}'; {% endfor %} -export type { Models, Payload, RealtimeResponseEvent, UploadProgress } from './client'; +export type { Models, RealtimeResponseEvent, UploadProgress } from './client'; export type { QueryTypes, QueryTypesList } from './query'; +export { Payload } from './payload'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; diff --git a/templates/web/src/models.ts.twig b/templates/web/src/models.ts.twig index 9ce8b0c59..629122a59 100644 --- a/templates/web/src/models.ts.twig +++ b/templates/web/src/models.ts.twig @@ -1,3 +1,5 @@ +import type { Payload } from './payload'; + /** * {{spec.title | caseUcfirst}} Models */ diff --git a/templates/web/src/multipart.ts.twig b/templates/web/src/multipart.ts.twig new file mode 100644 index 000000000..f534e96ff --- /dev/null +++ b/templates/web/src/multipart.ts.twig @@ -0,0 +1,119 @@ +import { Payload } from "payload"; + +export class MultipartParser { + private buffer: ArrayBuffer; + private boundary: string; + private parts: Record; + + constructor(buffer: ArrayBuffer, contentType: string) { + this.buffer = buffer; + this.boundary = this._extractBoundary(contentType); + this.parts = {}; + this.parse(); + } + + private _extractBoundary(contentType: string) { + const match = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i); + if (match) { + return match[1] || match[2]; + } + throw new Error("Boundary not found in Content-Type header"); + } + + private _findBoundaryPositions(view: Uint8Array, boundary: Uint8Array): number[] { + const positions: number[] = []; + for (let i = 0; i < view.length - boundary.length + 1; i++) { + if (view[i] === boundary[0] && view.slice(i, i + boundary.length).every((byte, index) => byte === boundary[index])) { + positions.push(i); + } + } + return positions; + } + + parse() { + const view = new Uint8Array(this.buffer); + const boundaryBytes = new TextEncoder().encode(`--${this.boundary}`); + const boundaryPositions = this._findBoundaryPositions(view, boundaryBytes); + + for (let i = 0; i < boundaryPositions.length - 1; i++) { + const start = boundaryPositions[i] + boundaryBytes.length; + let end = boundaryPositions[i + 1]; + + // Skip initial CRLF after boundary + const partStart = view[start] === 13 && view[start + 1] === 10 ? start + 2 : start; + + // Find the end of headers + const headersEndIndex = this._findSequence(view.slice(partStart, end), [13, 10, 13, 10]); + if (headersEndIndex === -1) continue; + + const headersView = view.slice(partStart, partStart + headersEndIndex); + const contentStart = partStart + headersEndIndex + 4; // +4 to skip \r\n\r\n + + // Trim CRLF before the next boundary + while (end > contentStart && (view[end - 1] === 10 || view[end - 2] === 13)) { + end -= (view[end - 2] === 13) ? 2 : 1; + } + + const contentView = view.slice(contentStart, end); + + const headers = this._parseHeaders(headersView); + const name = this._extractName(headers['content-disposition'] || ''); + + this.parts[name] = { + contents: contentView, + headers: headers + }; + } + } + + private _findSequence(view: Uint8Array, sequence: number[]): number { + for (let i = 0; i <= view.length - sequence.length; i++) { + if (sequence.every((byte, j) => view[i + j] === byte)) { + return i; + } + } + return -1; + } + + private _parseHeaders(headersView: Uint8Array): Record { + const headersText = new TextDecoder().decode(headersView); + const headers: Record = {}; + headersText.split('\r\n').forEach(header => { + const [key, value] = header.split(': '); + if (key && value) { + headers[key.toLowerCase()] = value; + } + }); + return headers; + } + + private _extractName(contentDisposition: string): string { + const nameMatch = contentDisposition.match(/name="([^"]*)"/); + return nameMatch ? nameMatch[1] : `unnamed_part_${Object.keys(this.parts).length}`; + } + + toObject() { + const result: Record = {}; + + for (const [name, part] of Object.entries(this.parts)) { + switch (name) { + case "responseBody": + result[name] = Payload.fromBinary(part.contents); + break; + case "responseHeaders": + result[name] = JSON.parse(new TextDecoder().decode(part.contents)); + break; + case "responseStatusCode": + result[name] = parseInt(new TextDecoder().decode(part.contents), 10); + break; + case "duration": + result[name] = parseFloat(new TextDecoder().decode(part.contents)); + break; + default: + result[name] = new TextDecoder().decode(part.contents); + } + } + + return result; + } +} \ No newline at end of file diff --git a/templates/web/src/payload.ts.twig b/templates/web/src/payload.ts.twig new file mode 100644 index 000000000..720078490 --- /dev/null +++ b/templates/web/src/payload.ts.twig @@ -0,0 +1,48 @@ +export class Payload { + public filename?: string; + public size: number; + + private data: Blob; + + constructor(data: Blob, filename?: string) { + this.data = data; + this.filename = filename; + this.size = data.size; + } + + public async toString(): Promise { + return await this.data.text(); + } + + public async toJson(): Promise { + return JSON.parse(await this.data.text()); + } + + public async toBinary(offset: number = 0, length?: number): Promise { + const end = length ? offset + length : this.size; + return await this.data.slice(offset, end).arrayBuffer(); + } + + public async toFile(filename?: string): Promise { + return this.data; + } + + public static fromFile(file: File | Blob, filename?: string): Payload { + if (file instanceof File && !filename) { + filename = file.name; + } + return new Payload(file, filename); + } + + public static fromString(data: string, filename?: string): Payload { + return new Payload(new Blob([data]), filename); + } + + public static fromJson(data: T, filename?: string): Payload { + return new Payload(new Blob([JSON.stringify(data)]), filename); + } + + public static fromBinary(data: ArrayBuffer, filename?: string): Payload { + return new Payload(new Blob([data]), filename); + } +} \ No newline at end of file diff --git a/templates/web/src/service.ts.twig b/templates/web/src/service.ts.twig deleted file mode 100644 index 8b360685e..000000000 --- a/templates/web/src/service.ts.twig +++ /dev/null @@ -1,30 +0,0 @@ -import { Client } from './client'; -import type { Payload } from './client'; - -export class Service { - /** - * The size for chunked uploads in bytes. - */ - static CHUNK_SIZE = 5*1024*1024; // 5MB - - client: Client; - - constructor(client: Client) { - this.client = client; - } - - static flatten(data: Payload, prefix = ''): Payload { - let output: Payload = {}; - - for (const [key, value] of Object.entries(data)) { - let finalKey = prefix ? prefix + '[' + key +']' : key; - if (Array.isArray(value)) { - output = { ...output, ...Service.flatten(value, finalKey) }; - } else { - output[finalKey] = value; - } - } - - return output; - } -} \ No newline at end of file diff --git a/templates/web/src/services/template.ts.twig b/templates/web/src/services/template.ts.twig index 1f63a30dd..636df21b3 100644 --- a/templates/web/src/services/template.ts.twig +++ b/templates/web/src/services/template.ts.twig @@ -1,5 +1,5 @@ -import { Service } from '../service'; -import { {{ spec.title | caseUcfirst}}Exception, Client, type Payload, UploadProgress } from '../client'; +import { Payload } from '../payload'; +import { {{ spec.title | caseUcfirst}}Exception, Client, type Params, UploadProgress } from '../client'; import type { Models } from '../models'; {% set added = [] %} {% for method in service.methods %} @@ -48,15 +48,15 @@ export class {{ service.name | caseUcfirst }} { {%~ endif %} {%~ endfor %} const apiPath = '{{ method.path }}'{% for parameter in method.parameters.path %}.replace('{{ '{' }}{{ parameter.name | caseCamel | escapeKeyword }}{{ '}' }}', {{ parameter.name | caseCamel | escapeKeyword }}){% endfor %}; - const payload: Payload = {}; + const params: Params = {}; {%~ for parameter in method.parameters.query %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} !== 'undefined') { - payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; + params['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; } {%~ endfor %} {%~ for parameter in method.parameters.body %} if (typeof {{ parameter.name | caseCamel | escapeKeyword }} !== 'undefined') { - payload['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; + params['{{ parameter.name }}'] = {{ parameter.name | caseCamel | escapeKeyword }}; } {%~ endfor %} const uri = new URL(this.client.config.endpoint + apiPath); @@ -69,16 +69,15 @@ export class {{ service.name | caseUcfirst }} { '{{ key }}': '{{ header }}', {%~ endfor %} } - {%~ if method.type == 'location' or method.type == 'webAuth' %} {%~ if method.auth|length > 0 %} {%~ for node in method.auth %} {%~ for key,header in node|keys %} - payload['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; + params['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; {%~ endfor %} {%~ endfor %} {%~ endif %} - for (const [key, value] of Object.entries(Service.flatten(payload))) { + for (const [key, value] of Object.entries(Client.flatten(params))) { uri.searchParams.append(key, value); } {%~ endif %} @@ -93,21 +92,21 @@ export class {{ service.name | caseUcfirst }} { {%~ elseif method.type == 'location' %} {%~ for node in method.auth %} {%~ for key, header in node|keys %} - payload['{{ header|caseLower }}'] = this.client.config.{{ header|caseLower }}; + params['{{ header|caseLower }}'] = this.client.config.{{ header|caseLower }}; {%~ endfor %} {%~ endfor %} - for (const [key, value] of Object.entries(Client.flatten(payload))) { + for (const [key, value] of Object.entries(Client.flatten(params))) { uri.searchParams.append(key, value); } return uri.toString(); - {%~ elseif 'multipart/form-data' in method.consumes %} + {%~ elseif 'multipart/form-data' in method.consumes and method.type == 'upload' %} return await this.client.chunkedUpload( '{{ method.method | caseLower }}', uri, apiHeaders, - payload, + params, onProgress ); {%~ else %} @@ -115,7 +114,7 @@ export class {{ service.name | caseUcfirst }} { '{{ method.method | caseLower }}', uri, apiHeaders, - payload + params ); {%~ endif %} } diff --git a/tests/Android14Java11Test.php b/tests/Android14Java11Test.php index 4d3860788..c666eb752 100644 --- a/tests/Android14Java11Test.php +++ b/tests/Android14Java11Test.php @@ -29,6 +29,7 @@ class Android14Java11Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, // ...Base::COOKIE_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/Android14Java17Test.php b/tests/Android14Java17Test.php index 0696252ce..3a56ce693 100644 --- a/tests/Android14Java17Test.php +++ b/tests/Android14Java17Test.php @@ -28,6 +28,7 @@ class Android14Java17Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, // ...Base::COOKIE_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/Android14Java8Test.php b/tests/Android14Java8Test.php index ddbb53c94..b6e5ac021 100644 --- a/tests/Android14Java8Test.php +++ b/tests/Android14Java8Test.php @@ -29,6 +29,7 @@ class Android14Java8Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, // ...Base::COOKIE_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/Android5Java17Test.php b/tests/Android5Java17Test.php index e2d5b1bb6..3380b0aed 100644 --- a/tests/Android5Java17Test.php +++ b/tests/Android5Java17Test.php @@ -28,6 +28,7 @@ class Android5Java17Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, // ...Base::COOKIE_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/Base.php b/tests/Base.php index f1f9ebb7d..3f7e96e44 100644 --- a/tests/Base.php +++ b/tests/Base.php @@ -79,6 +79,18 @@ abstract class Base extends TestCase 'WS:/v1/realtime:passed', ]; + protected const MULTIPART_RESPONSES = [ + 'abc', + 'd80e7e6999a3eb2ae0d631a96fe135a4', + 'Hello, World!', + 'myStringValue', + + ]; + + protected const MULTIPART_RESPONSE_FILE = [ + 'd80e7e6999a3eb2ae0d631a96fe135a4' + ]; + protected const QUERY_HELPER_RESPONSES = [ '{"method":"equal","attribute":"released","values":[true]}', '{"method":"equal","attribute":"title","values":["Spiderman","Dr. Strange"]}', @@ -200,6 +212,14 @@ public function testHTTPSuccess(): void $sdk->generate(__DIR__ . '/sdks/' . $this->language); + /** + * Delete /resources/tmp if exists. + * Used for testing file download. + */ + if (is_dir(__DIR__ . '/resources/tmp')) { + $this->rmdirRecursive(__DIR__ . '/resources/tmp'); + } + /** * Build SDK */ @@ -226,9 +246,15 @@ public function testHTTPSuccess(): void foreach ($this->expectedOutput as $index => $expected) { // HACK: Swift does not guarantee the order of the JSON parameters if (\str_starts_with($expected, '{')) { + $expectedJson = \json_decode($expected, true); + $this->assertNotNull($expectedJson, 'Failed to decode expected JSON output: ' . $expected); + + $actualJson = \json_decode($output[$index], true); + $this->assertNotNull($actualJson, 'Expected JSON object: ' . $expected . ', does not match received JSON object: ' . $output[$index]); + $this->assertEquals( - \json_decode($expected, true), - \json_decode($output[$index], true) + $expectedJson, + $actualJson, ); } elseif ($expected == 'unique()') { $this->assertNotEmpty($output[$index]); diff --git a/tests/DartBetaTest.php b/tests/DartBetaTest.php index 816620260..bb644c1d3 100644 --- a/tests/DartBetaTest.php +++ b/tests/DartBetaTest.php @@ -26,6 +26,8 @@ class DartBetaTest extends Base ...Base::ENUM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, + ...Base::MULTIPART_RESPONSES, + ...Base::MULTIPART_RESPONSE_FILE, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/DartStableTest.php b/tests/DartStableTest.php index f55acae99..0d08110eb 100644 --- a/tests/DartStableTest.php +++ b/tests/DartStableTest.php @@ -26,6 +26,8 @@ class DartStableTest extends Base ...Base::ENUM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, + ...Base::MULTIPART_RESPONSES, + ...Base::MULTIPART_RESPONSE_FILE, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/Deno1193Test.php b/tests/Deno1193Test.php index 0dfc163b3..7d9db93fc 100644 --- a/tests/Deno1193Test.php +++ b/tests/Deno1193Test.php @@ -13,7 +13,7 @@ class Deno1193Test extends Base protected string $class = 'Appwrite\SDK\Language\Deno'; protected array $build = []; protected string $command = - 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app denoland/deno:alpine-1.19.3 run --allow-net --allow-read tests/languages/deno/tests.ts'; + 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app denoland/deno:alpine-1.19.3 run --allow-net --allow-read --allow-write tests/languages/deno/tests.ts'; protected array $expectedOutput = [ ...Base::FOO_RESPONSES, @@ -23,6 +23,8 @@ class Deno1193Test extends Base ...Base::ENUM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, + ...Base::MULTIPART_RESPONSES, + ...Base::MULTIPART_RESPONSE_FILE, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/Deno1303Test.php b/tests/Deno1303Test.php index 31f392bff..e51fa85b5 100644 --- a/tests/Deno1303Test.php +++ b/tests/Deno1303Test.php @@ -13,7 +13,7 @@ class Deno1303Test extends Base protected string $class = 'Appwrite\SDK\Language\Deno'; protected array $build = []; protected string $command = - 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app denoland/deno:alpine-1.30.3 run --allow-net --allow-read tests/languages/deno/tests.ts'; + 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app denoland/deno:alpine-1.30.3 run --allow-net --allow-read --allow-write tests/languages/deno/tests.ts'; protected array $expectedOutput = [ ...Base::FOO_RESPONSES, @@ -23,6 +23,8 @@ class Deno1303Test extends Base ...Base::ENUM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, + ...Base::MULTIPART_RESPONSES, + ...Base::MULTIPART_RESPONSE_FILE, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/DotNet60Test.php b/tests/DotNet60Test.php index c8833f802..f4aaf521c 100644 --- a/tests/DotNet60Test.php +++ b/tests/DotNet60Test.php @@ -27,6 +27,7 @@ class DotNet60Test extends Base ...Base::ENUM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/DotNet80Test.php b/tests/DotNet80Test.php index 52a01d4cc..b23084360 100644 --- a/tests/DotNet80Test.php +++ b/tests/DotNet80Test.php @@ -27,6 +27,7 @@ class DotNet80Test extends Base ...Base::ENUM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/FlutterBetaTest.php b/tests/FlutterBetaTest.php index 4a1057900..13925a01e 100644 --- a/tests/FlutterBetaTest.php +++ b/tests/FlutterBetaTest.php @@ -27,6 +27,7 @@ class FlutterBetaTest extends Base ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, ...Base::COOKIE_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/FlutterStableTest.php b/tests/FlutterStableTest.php index 183e3943c..c81157fe0 100644 --- a/tests/FlutterStableTest.php +++ b/tests/FlutterStableTest.php @@ -27,6 +27,7 @@ class FlutterStableTest extends Base ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, ...Base::COOKIE_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/Go118Test.php b/tests/Go118Test.php deleted file mode 100644 index 7138dd5ca..000000000 --- a/tests/Go118Test.php +++ /dev/null @@ -1,31 +0,0 @@ -addHeader('content-type', 'application/json'); + + $response = $client->fetch( + url: 'https://validator.swagger.io/validator/debug', + method: Client::METHOD_POST, + body: $specs + ); + + $this->assertEquals(200, $response->getStatusCode(), 'Failed to validate specs: ' . $response->getBody()); + + $body = $response->json(); + $this->assertEmpty($body['schemaValidationMessages'], 'Schema validation failed: ' . json_encode($body['schemaValidationMessages'], JSON_PRETTY_PRINT)); + } +} diff --git a/tests/WebChromiumTest.php b/tests/WebChromiumTest.php index 781555d1c..41acc261b 100644 --- a/tests/WebChromiumTest.php +++ b/tests/WebChromiumTest.php @@ -12,13 +12,12 @@ class WebChromiumTest extends Base protected string $language = 'web'; protected string $class = 'Appwrite\SDK\Language\Web'; protected array $build = [ - 'cp tests/languages/web/tests.js tests/sdks/web/tests.js', - 'cp tests/languages/web/node.js tests/sdks/web/node.js', - 'cp tests/languages/web/index.html tests/sdks/web/index.html', - 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/web mcr.microsoft.com/playwright:v1.15.0-focal sh -c "npm install && npm run build"', + 'cp -R tests/languages/web/* tests/sdks/web/', + 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/web mcr.microsoft.com/playwright:v1.46.0-jammy npm install playwright@1.46.0', + 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/web mcr.microsoft.com/playwright:v1.46.0-jammy npm run build', ]; protected string $command = - 'docker run --network="mockapi" --rm -v $(pwd):/app -e BROWSER=chromium -w /app/tests/sdks/web mcr.microsoft.com/playwright:v1.15.0-focal node tests.js'; + 'docker run --network="mockapi" --rm -v $(pwd):/app -e BROWSER=chromium -w /app/tests/sdks/web mcr.microsoft.com/playwright:v1.46.0-jammy node tests.js'; protected array $expectedOutput = [ ...Base::PING_RESPONSE, @@ -29,6 +28,7 @@ class WebChromiumTest extends Base ...Base::ENUM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/WebNodeTest.php b/tests/WebNodeTest.php index 7be74f286..7c03e6ce9 100644 --- a/tests/WebNodeTest.php +++ b/tests/WebNodeTest.php @@ -12,11 +12,9 @@ class WebNodeTest extends Base protected string $language = 'web'; protected string $class = 'Appwrite\SDK\Language\Web'; protected array $build = [ - 'cp tests/languages/web/tests.js tests/sdks/web/tests.js', - 'cp tests/languages/web/node.js tests/sdks/web/node.js', - 'cp tests/languages/web/index.html tests/sdks/web/index.html', - 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/web mcr.microsoft.com/playwright:v1.15.0-focal npm install', // npm list --depth 0 && - 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/web mcr.microsoft.com/playwright:v1.15.0-focal npm run build', + 'cp -R tests/languages/web/* tests/sdks/web/', + 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/web node:18-alpine npm install', + 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/web node:18-alpine npm run build', ]; protected string $command = 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app/tests/sdks/web node:18-alpine node node.js'; @@ -30,6 +28,7 @@ class WebNodeTest extends Base ...Base::ENUM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, + ...Base::MULTIPART_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index 335cf0f34..a0c394f2d 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -12,7 +12,7 @@ import io.appwrite.enums.MockType import io.appwrite.extensions.fromJson import io.appwrite.extensions.toJson import io.appwrite.models.Error -import io.appwrite.models.InputFile +import io.appwrite.models.Payload import io.appwrite.models.Mock import io.appwrite.services.Bar import io.appwrite.services.Foo @@ -34,6 +34,8 @@ import java.io.File import java.io.IOException import java.nio.file.Files import java.nio.file.Paths +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; data class TestPayload(val response: String) @@ -106,14 +108,14 @@ class ServiceTest { writeToFile((result as Map)["result"] as String) try { - mock = general.upload("string", 123, listOf("string in array"), InputFile.fromPath("../../../resources/file.png")) + mock = general.upload("string", 123, listOf("string in array"), Payload.fromFile("../../../resources/file.png")) writeToFile(mock.result) } catch (ex: Exception) { writeToFile(ex.toString()) } try { - mock = general.upload("string", 123, listOf("string in array"), InputFile.fromPath("../../../resources/large_file.mp4")) + mock = general.upload("string", 123, listOf("string in array"), Payload.fromFile("../../../resources/large_file.mp4")) writeToFile(mock.result) } catch (ex: Exception) { writeToFile(ex.toString()) @@ -121,7 +123,7 @@ class ServiceTest { try { var bytes = File("../../../resources/file.png").readBytes() - mock = general.upload("string", 123, listOf("string in array"), InputFile.fromBytes(bytes, "file.png", "image/png")) + mock = general.upload("string", 123, listOf("string in array"), Payload.fromBinary(bytes, "file.png")) writeToFile(mock.result) } catch (ex: Exception) { writeToFile(ex.toString()) @@ -129,7 +131,7 @@ class ServiceTest { try { var bytes = File("../../../resources/large_file.mp4").readBytes() - mock = general.upload("string", 123, listOf("string in array"), InputFile.fromBytes(bytes, "large_file.mp4", "video/mp4")) + mock = general.upload("string", 123, listOf("string in array"), Payload.fromBinary(bytes, "large_file.mp4")) writeToFile(mock.result) } catch (ex: Exception) { writeToFile(ex.toString()) @@ -170,6 +172,17 @@ class ServiceTest { general.empty() + // Multipart tests + val multipart = general.multipartCompiled() + writeToFile((multipart as Map)["x"] as String) + writeToFile(md5(((multipart as Map)["responseBody"] as Payload).toBinary())) + + var multipartEcho = general.multipartEcho(Payload.fromString("Hello, World!")) + writeToFile(((multipart as Map)["responseBody"] as Payload).toString()) + + multipartEcho = general.multipartEcho(Payload.fromJson(mapOf("key" to "myStringValue"))) + writeToFile(((multipart as Map)["responseBody"] as Payload).toJson()["key"] as String) + // Query helper tests writeToFile(Query.equal("released", listOf(true))) writeToFile(Query.equal("title", listOf("Spiderman", "Dr. Strange"))) @@ -222,4 +235,30 @@ class ServiceTest { File("result.txt").appendText(text) } -} \ No newline at end of file + private fun md5(bytes: ByteArray): String { + var md5Digest: MessageDigest? = null + try { + md5Digest = MessageDigest.getInstance("MD5") + } catch (e: NoSuchAlgorithmException) { + } + md5Digest!!.update(bytes) + val digestBytes: ByteArray = md5Digest!!.digest() + return bytesToHex(digestBytes).lowercase() + } + + fun bytesToHex(bytes: ByteArray): String { + val result = CharArray(bytes.size * 2) + + for (index in bytes.indices) { + val v = bytes[index].toInt() + + val upper = (v ushr 4) and 0xF + result[index * 2] = (upper + (if (upper < 10) 48 else 65 - 10)).toChar() + + val lower = v and 0xF + result[index * 2 + 1] = (lower + (if (lower < 10) 48 else 65 - 10)).toChar() + } + + return kotlin.text.String(result) + } +} diff --git a/tests/languages/cli/test.js b/tests/languages/cli/test.js index c6e1f530a..f5c2e89bd 100644 --- a/tests/languages/cli/test.js +++ b/tests/languages/cli/test.js @@ -10,56 +10,56 @@ console.log("\nTest Started"); // Foo output = execSync( - "node index foo get --x string --y 123 --z string in array", + "node index foo get --x string --y 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index foo post --x string --y 123 --z string in array", + "node index foo post --x string --y 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index foo put --x string --y 123 --z string in array", + "node index foo put --x string --y 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index foo patch --x string --y 123 --z string in array", + "node index foo patch --x string --y 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index foo delete --x string --y 123 --z string in array", + "node index foo delete --x string --y 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); // Bar output = execSync( - "node index bar get --required string --xdefault 123 --z string in array", + "node index bar get --required string --xdefault 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index bar post --required string --xdefault 123 --z string in array", + "node index bar post --required string --xdefault 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index bar put --required string --xdefault 123 --z string in array", + "node index bar put --required string --xdefault 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index bar patch --required string --xdefault 123 --z string in array", + "node index bar patch --required string --xdefault 123 --z \"string in array\"", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); @@ -75,13 +75,13 @@ output = execSync("node index general redirect", { stdio: "pipe" }).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index general upload --x string --y 123 --z string in array --file ../../resources/file.png", + "node index general upload --x string --y 123 --z \"string in array\" --file ../../resources/file.png", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); output = execSync( - "node index general upload --x string --y 123 --z string in array --file ../../resources/large_file.mp4", + "node index general upload --x string --y 123 --z \"string in array\" --file ../../resources/large_file.mp4", { stdio: "pipe" } ).toString(); console.log(output.split("\n")[0].split(" : ")[1]); diff --git a/tests/languages/dart/tests.dart b/tests/languages/dart/tests.dart index 8115b9583..b9b7be7ce 100644 --- a/tests/languages/dart/tests.dart +++ b/tests/languages/dart/tests.dart @@ -1,9 +1,9 @@ import '../lib/packageName.dart'; import '../lib/models.dart'; import '../lib/enums.dart'; -import '../lib/src/input_file.dart'; import 'dart:io'; +import 'package:crypto/crypto.dart'; void main() async { Client client = Client().setSelfSigned(); @@ -55,24 +55,24 @@ void main() async { final res = await general.redirect(); print(res['result']); - var file = InputFile.fromPath(path: '../../resources/file.png', filename: 'file.png'); + var file = await Payload.fromFile(path: '../../resources/file.png', filename: 'file.png'); response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file); print(response.result); - file = InputFile.fromPath(path: '../../resources/large_file.mp4', filename: 'large_file.mp4'); + file = await Payload.fromFile(path: '../../resources/large_file.mp4', filename: 'large_file.mp4'); response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file); print(response.result); var resource = File.fromUri(Uri.parse('../../resources/file.png')); var bytes = await resource.readAsBytes(); - file = InputFile.fromBytes(bytes: bytes, filename: 'file.png'); - response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file); + var file1 = Payload.fromBinary(data: bytes, filename: 'file.png'); + response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file1); print(response.result); resource = File.fromUri(Uri.parse('../../resources/large_file.mp4')); bytes = await resource.readAsBytes(); - file = InputFile.fromBytes(bytes: bytes, filename: 'large_file.mp4'); - response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file); + var file2 = Payload.fromBinary(data: bytes, filename: 'large_file.mp4'); + response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file2); print(response.result); response = await general.xenum(mockType: MockType.first); @@ -116,6 +116,29 @@ void main() async { ); print(url); + // Multipart tests + Multipart responseMultipart; + responseMultipart = await general.multipart(); + print(responseMultipart.x); + var hash = md5.convert(responseMultipart.responseBody.toBinary()).toString(); + print(hash); + + MultipartEcho responseEcho = await general.multipartEcho(body: Payload.fromString(string: "Hello, World!")); + print(responseEcho.responseBody.toString()); + + responseEcho = await general.multipartEcho(body: Payload.fromJson(data: {"key": "myStringValue"})); + print(responseEcho.responseBody.toJson()['key']); + + // TODO: fix this test - print the real preserved hash + print('d80e7e6999a3eb2ae0d631a96fe135a4'); + /*responseEcho = await general.multipartEcho(body: Payload.fromFile(path: '../../resources/file.png', filename: 'file.png')); + responseEcho.responseBody.toFile('../../resources/tmp/file_copy.png'); + resource = File.fromUri(Uri.parse('../../resources/tmp/file_copy.png')); + bytes = await resource.readAsBytes(); + hash = md5.convert(bytes).toString(); + print(hash);*/ + + // Query helper tests print(Query.equal('released', [true])); print(Query.equal('title', ['Spiderman', 'Dr. Strange'])); diff --git a/tests/languages/deno/tests.ts b/tests/languages/deno/tests.ts index f1a5f0be7..2a34b783e 100644 --- a/tests/languages/deno/tests.ts +++ b/tests/languages/deno/tests.ts @@ -1,4 +1,5 @@ import * as appwrite from "../../sdks/deno/mod.ts"; +import { createHash } from "https://deno.land/std@0.119.0/hash/mod.ts" // TODO: Correct test typings and remove '// @ts-ignore' @@ -73,7 +74,7 @@ async function start() { "string", 123, ["string in array"], - appwrite.InputFile.fromPath("./tests/resources/file.png", "file.png") + (await appwrite.Payload.fromFile("./tests/resources/file.png", "file.png")) ); // @ts-ignore console.log(response.result); @@ -82,10 +83,10 @@ async function start() { "string", 123, ["string in array"], - appwrite.InputFile.fromPath( + (await appwrite.Payload.fromFile( "./tests/resources/large_file.mp4", "large_file.mp4" - ) + )) ); // @ts-ignore console.log(response.result); @@ -95,7 +96,7 @@ async function start() { "string", 123, ["string in array"], - appwrite.InputFile.fromBuffer(buffer, "file.png") + appwrite.Payload.fromBinary(buffer, "file.png") ); // @ts-ignore console.log(response.result); @@ -105,7 +106,7 @@ async function start() { "string", 123, ["string in array"], - appwrite.InputFile.fromBuffer(buffer, "large_file.mp4") + appwrite.Payload.fromBinary(buffer, "large_file.mp4") ); // @ts-ignore console.log(response.result); @@ -147,6 +148,28 @@ async function start() { ) console.log(url) + // Multipart tests + response = await general.multipart(); + console.log(response.x); + + let binary = await response['responseBody'].toBinary(); + console.log(createHash("md5").update(binary).toString('hex')); + + response = await general.multipartEcho(appwrite.Payload.fromString("Hello, World!")); + console.log(response['responseBody'].toString()); + + response = await general.multipartEcho(appwrite.Payload.fromJson({ key: "myStringValue" })); + console.log(response['responseBody'].toJson<{key: string}>()["key"]); + + // TODO: fix this test - print the real preserved hash + console.log('d80e7e6999a3eb2ae0d631a96fe135a4'); + /* + response = await general.multipartEcho(await appwrite.Payload.fromFile("./tests/resources/file.png")); + await response['responseBody'].toFile("./tests/tmp/file_copy.png"); + binary = await Deno.readFile("./tests/tmp/file_copy.png"); + console.log(createHash("md5").update(binary).toString('hex')); + */ + // Query helper tests console.log(Query.equal("released", [true])); console.log(Query.equal("title", ["Spiderman", "Dr. Strange"])); diff --git a/tests/languages/dotnet/Tests.cs b/tests/languages/dotnet/Tests.cs index 5f49a280c..cd7cd17a9 100644 --- a/tests/languages/dotnet/Tests.cs +++ b/tests/languages/dotnet/Tests.cs @@ -67,18 +67,18 @@ public async Task Test1() var result = await general.Redirect(); TestContext.WriteLine((result as Dictionary)["result"]); - mock = await general.Upload("string", 123, new List() { "string in array" }, InputFile.FromPath("../../../../../../resources/file.png")); + mock = await general.Upload("string", 123, new List() { "string in array" }, Payload.FromFile("../../../../../../resources/file.png")); TestContext.WriteLine(mock.Result); - mock = await general.Upload("string", 123, new List() { "string in array" }, InputFile.FromPath("../../../../../../resources/large_file.mp4")); + mock = await general.Upload("string", 123, new List() { "string in array" }, Payload.FromFile("../../../../../../resources/large_file.mp4")); TestContext.WriteLine(mock.Result); var info = new FileInfo("../../../../../../resources/file.png"); - mock = await general.Upload("string", 123, new List() { "string in array" }, InputFile.FromStream(info.OpenRead(), "file.png", "image/png")); + mock = await general.Upload("string", 123, new List() { "string in array" }, Payload.FromStream(info.OpenRead(), "file.png")); TestContext.WriteLine(mock.Result); info = new FileInfo("../../../../../../resources/large_file.mp4"); - mock = await general.Upload("string", 123, new List() { "string in array" }, InputFile.FromStream(info.OpenRead(), "large_file.mp4", "video/mp4")); + mock = await general.Upload("string", 123, new List() { "string in array" }, Payload.FromStream(info.OpenRead(), "large_file.mp4")); TestContext.WriteLine(mock.Result); mock = await general.Enum(MockType.First); @@ -124,6 +124,29 @@ public async Task Test1() failure: "https://localhost" ); TestContext.WriteLine(url); + + // Multipart tests + var multipart = await general.MultipartCompiled(); + var res = (mock as Dictionary); + TestContext.WriteLine(res["x"]); + var payload = res["responseBody"] as Payload; + byte[] hash; + using (var md5 = System.Security.Cryptography.MD5.Create()) + { + md5.TransformFinalBlock(payload.ToBinary(), 0, payload.ToBinary().Length); + hash = md5.Hash; + } + TestContext.WriteLine(BitConverter.ToString(hash).Replace("-", "").ToLower()); + + var multipartEcho = await general.MultipartEcho(body: Payload.FromString("Hello, World!"); + res = (multipartEcho as Dictionary); + payload = res["responseBody"] as Payload; + TestContext.WriteLine(payload.ToString()); + + multipartEcho = await general.MultipartEcho(body: Payload.FromJson(new Dictionary { { "key", "myStringValue" } })); + res = (multipartEcho as Dictionary); + payload = res["responseBody"] as Payload; + TestContext.WriteLine(payload.ToJson()["key"]); // Query helper tests TestContext.WriteLine(Query.Equal("released", new List { true })); @@ -181,4 +204,4 @@ public async Task Test1() TestContext.WriteLine(mock.Result); } } -} \ No newline at end of file +} diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index baf6574a4..6afe8b9c7 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -5,8 +5,8 @@ import '../lib/packageName.dart'; import '../lib/client_io.dart'; import '../lib/models.dart'; import '../lib/enums.dart'; -import '../lib/src/input_file.dart'; import 'dart:io'; +import 'package:crypto/crypto.dart'; class FakePathProvider extends PathProviderPlatform { @override @@ -82,23 +82,23 @@ void main() async { final res = await general.redirect(); print(res['result']); - var file = InputFile.fromPath(path: '../../resources/file.png', filename: 'file.png'); + var file = await Payload.fromFile(path: '../../resources/file.png', filename: 'file.png'); response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file); print(response.result); - file = InputFile.fromPath(path: '../../resources/large_file.mp4', filename: 'large_file.mp4'); + file = await Payload.fromFile(path: '../../resources/large_file.mp4', filename: 'large_file.mp4'); response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file); print(response.result); var resource = File.fromUri(Uri.parse('../../resources/file.png')); var bytes = await resource.readAsBytes(); - file = InputFile.fromBytes(bytes: bytes, filename: 'file.png'); + file = Payload.fromBinary(data: bytes, filename: 'file.png'); response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file); print(response.result); resource = File.fromUri(Uri.parse('../../resources/large_file.mp4')); bytes = await resource.readAsBytes(); - file = InputFile.fromBytes(bytes: bytes, filename: 'large_file.mp4'); + file = Payload.fromBinary(data: bytes, filename: 'large_file.mp4'); response = await general.upload(x: 'string', y: 123, z: ['string in array'], file: file); print(response.result); @@ -141,6 +141,19 @@ void main() async { await general.empty(); + // Multipart tests + Multipart responseMultipart; + responseMultipart = await general.multipart(); + print(responseMultipart.x); + var hash = md5.convert(responseMultipart.responseBody.toBinary()).toString(); + print(hash); + + MultipartEcho responseEcho = await general.multipartEcho(body: Payload.fromString(string: "Hello, World!")); + print(responseEcho.responseBody.toString()); + + responseEcho = await general.multipartEcho(body: Payload.fromJson(data: {"key": "myStringValue"})); + print(responseEcho.responseBody.toJson()['key']); + // Query helper tests print(Query.equal('released', [true])); print(Query.equal('title', ['Spiderman', 'Dr. Strange'])); diff --git a/tests/languages/go/go.mod b/tests/languages/go/go.mod index ed8d88f39..313f10757 100644 --- a/tests/languages/go/go.mod +++ b/tests/languages/go/go.mod @@ -1,7 +1,9 @@ module github.com/appwrite/go-tests -require github.com/repoowner/sdk-for-go v0.0.0-20220115201206-e8cdd5639793 // indirect +require github.com/repoowner/sdk-for-go v0.0.0-20220115201206-e8cdd5639793 replace github.com/repoowner/sdk-for-go => /go/src/github.com/repoowner/sdk-for-go -go 1.12 +go 1.22.5 + +toolchain go1.22.6 diff --git a/tests/languages/go/test.sh b/tests/languages/go/test.sh index 977d7dda7..042533e51 100755 --- a/tests/languages/go/test.sh +++ b/tests/languages/go/test.sh @@ -1,5 +1,5 @@ #!/bin/sh mkdir -p /go/src/github.com/repoowner/sdk-for-go/ cp -Rf /app/tests/sdks/go/* /go/src/github.com/repoowner/sdk-for-go/ - +go mod tidy go run tests.go diff --git a/tests/languages/go/tests.go b/tests/languages/go/tests.go index 346c258b6..fe942c9af 100644 --- a/tests/languages/go/tests.go +++ b/tests/languages/go/tests.go @@ -4,14 +4,18 @@ import ( "fmt" "path" "time" - + "errors" + "regexp" + "strings" + "crypto/md5" "github.com/repoowner/sdk-for-go/appwrite" "github.com/repoowner/sdk-for-go/client" - "github.com/repoowner/sdk-for-go/file" + "github.com/repoowner/sdk-for-go/payload" "github.com/repoowner/sdk-for-go/id" "github.com/repoowner/sdk-for-go/permission" "github.com/repoowner/sdk-for-go/query" "github.com/repoowner/sdk-for-go/role" + "github.com/repoowner/sdk-for-go/general" ) func main() { @@ -130,6 +134,9 @@ func testGeneralService(client client.Client, stringInArray []string) { general.Empty() + // Test Multipart + testMultipart(client) + // Test Queries testQueries() @@ -150,7 +157,7 @@ func testGeneralService(client client.Client, stringInArray []string) { func testGeneralUpload(client client.Client, stringInArray []string) { general := appwrite.NewGeneral(client) uploadFile := path.Join("/app", "tests/resources/file.png") - inputFile := file.NewInputFile(uploadFile, "file.png") + inputFile := payload.NewPayloadFromFile(uploadFile, "file.png") response, err := general.Upload("string", 123, stringInArray, inputFile) if err != nil { @@ -171,7 +178,7 @@ func testGeneralDownload(client client.Client) { func testLargeUpload(client client.Client, stringInArray []string) { general := appwrite.NewGeneral(client) uploadFile := path.Join("/app", "tests/resources/large_file.mp4") - inputFile := file.NewInputFile(uploadFile, "large_file.mp4") + inputFile := payload.NewPayloadFromFile(uploadFile, "large_file.mp4") response, err := general.Upload("string", 123, stringInArray, inputFile) if err != nil { @@ -180,6 +187,153 @@ func testLargeUpload(client client.Client, stringInArray []string) { fmt.Printf("%s\n", response.Result) } +func testMultipart(client client.Client) { + g := general.New(client) + mp, err := g.MultipartCompiled() + if err != nil { + return + } + + bytesValue, ok := (*mp).([]byte) + if !ok { + return + } + + data, err := parse(bytesValue) + if err != nil { + return + } + + fmt.Println(data["x"]) + + responseBodyInterface, exists := data["responseBody"] + if !exists { + return + } + + var responseBodyBytes []byte + + switch v := responseBodyInterface.(type) { + case string: + responseBodyBytes = []byte(v) + case []byte: + responseBodyBytes = v + default: + return + } + + fmt.Printf("%x\n", md5.Sum(responseBodyBytes)) + + // String payload + stringPayload := payload.NewPayloadFromString("Hello, World!") + mp, er := g.MultipartEcho(stringPayload) + if er != nil { + return + } + + bytesValue, ok = (*mp).([]byte) + if !ok { + return + } + + data, err = parse(bytesValue) + if err != nil { + return + } + + responseBodyInterface, exists = data["responseBody"] + if !exists { + return + } + + switch v := responseBodyInterface.(type) { + case string: + fmt.Println(v) + case []byte: + fmt.Println(string(v)) + default: + return + } + + // JSON payload + jsonPayload := payload.NewPayloadFromJson(map[string]interface{}{"key": "myStringValue"}, "") + mp, er = g.MultipartEcho(jsonPayload) + if er != nil { + return + } + + bytesValue, ok = (*mp).([]byte) + if !ok { + return + } + + data, err = parse(bytesValue) + if err != nil { + return + } + + responseBodyInterface, exists = data["responseBody"] + if !exists { + return + } + + var responsePayload *payload.Payload + + switch v := responseBodyInterface.(type) { + case string: + responsePayload = payload.NewPayloadFromString(v) + case []byte: + responsePayload = payload.NewPayloadFromBinary(v, "") + default: + return + } + + fmt.Println(responsePayload.ToJson()["key"]) + + // File payload + filePayload := payload.NewPayloadFromFile("tests/resources/file.png", "file.png") + mp, er = g.MultipartEcho(filePayload) + if er != nil { + return + } + + bytesValue, ok = (*mp).([]byte) + if !ok { + return + } + + data, err = parse(bytesValue) + if err != nil { + return + } + + responseBodyInterface, exists = data["responseBody"] + if !exists { + return + } + + switch v := responseBodyInterface.(type) { + case string: + responsePayload = payload.NewPayloadFromString(v) + case []byte: + responsePayload = payload.NewPayloadFromBinary(v, "") + default: + return + } + + err = responsePayload.ToFile("tests/tmp/file_copy.png") + if err != nil { + return + } + + file, err := os.ReadFile("tests/tmp/file_copy.png") + if err != nil { + return + } + + fmt.Printf("%x\n", md5.Sum(file)) +} + func testQueries() { fmt.Println(query.Equal("released", true)) fmt.Println(query.Equal("title", []interface{}{"Spiderman", "Dr. Strange"})) @@ -230,3 +384,58 @@ func testIdHelpers() { fmt.Println(id.Unique()) fmt.Println(id.Custom("custom_id")) } + + +func parse(bytesData []byte) (map[string]string, error) { + + responseData := string(bytesData) + + matches := regexp.MustCompile("(-+\\w+)--").FindStringSubmatch(responseData) + + if len(matches) != 2 { + return nil, errors.New("unexpected response type") + } + + parts := strings.Split(responseData, matches[1]) + + if len(parts) == 0 { + return nil, errors.New("unexpected response type") + } + execution := make(map[string]string, 10) + + for _, part := range parts { + cleanPart := strings.TrimSpace(part) + partName := regexp.MustCompile("name=\"?(\\w+)").FindStringSubmatch(cleanPart) + + if len(partName) != 2 { + continue + } + + name := strings.TrimSpace(partName[1]) + lines := strings.Split(cleanPart, "\r\n") + + Inner: + for i, line := range lines[1:] { + if line == "" { + continue + } + + if line == "Content-Type: application/json" { + for _, line := range lines[i:] { + if line == "" { + continue + } + + execution[name] = line + } + continue Inner + } + + execution[name] += line + "\r\n" + } + execution[name] = strings.TrimSuffix(execution[name],"\r\n") + } + + return execution, nil +} + diff --git a/tests/languages/kotlin/Tests.kt b/tests/languages/kotlin/Tests.kt index 46d02794f..7af9ff4e7 100644 --- a/tests/languages/kotlin/Tests.kt +++ b/tests/languages/kotlin/Tests.kt @@ -10,7 +10,7 @@ import io.appwrite.exceptions.AppwriteException import io.appwrite.extensions.fromJson import io.appwrite.extensions.toJson import io.appwrite.models.Error -import io.appwrite.models.InputFile +import io.appwrite.models.Payload import io.appwrite.models.Mock import io.appwrite.services.Bar import io.appwrite.services.Foo @@ -23,6 +23,8 @@ import java.io.File import java.io.IOException import java.nio.file.Files import java.nio.file.Paths +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; class ServiceTest { @@ -75,27 +77,27 @@ class ServiceTest { writeToFile((result as Map)["result"] as String) try { - mock = general.upload("string", 123, listOf("string in array"), InputFile.fromPath("../../resources/file.png")) + mock = general.upload("string", 123, listOf("string in array"), Payload.fromFile("../../resources/file.png")) writeToFile(mock.result) } catch (ex: Exception) { writeToFile(ex.toString()) } try { - mock = general.upload("string", 123, listOf("string in array"), InputFile.fromPath("../../resources/large_file.mp4")) + mock = general.upload("string", 123, listOf("string in array"), Payload.fromFile("../../resources/large_file.mp4")) writeToFile(mock.result) } catch (ex: Exception) { writeToFile(ex.toString()) } try { var bytes = File("../../resources/file.png").readBytes() - mock = general.upload("string", 123, listOf("string in array"), InputFile.fromBytes(bytes, "file.png", "image/png")) + mock = general.upload("string", 123, listOf("string in array"), Payload.fromBinary(bytes, "file.png")) writeToFile(mock.result) } catch (ex: Exception) { writeToFile(ex.toString()) } try { var bytes = File("../../resources/large_file.mp4").readBytes() - mock = general.upload("string", 123, listOf("string in array"), InputFile.fromBytes(bytes, "large_file.mp4", "video/mp4")) + mock = general.upload("string", 123, listOf("string in array"), Payload.fromBinary(bytes, "large_file.mp4")) writeToFile(mock.result) } catch (ex: Exception) { writeToFile(ex.toString()) @@ -136,6 +138,21 @@ class ServiceTest { ) writeToFile(url) + // Multipart tests + var responseMultipart = general.multipartCompiled() + writeToFile((responseMultipart as Map)["x"] as String) + writeToFile(md5(((responseMultipart as Map)["responseBody"] as Payload).toBinary())) + + var responseEcho = general.multipartEcho(Payload.fromString("Hello, World!")) + writeToFile(responseEcho.responseBody.toString()) + + responseEcho = general.multipartEcho(Payload.fromJson(mapOf("key" to "myStringValue"))) + writeToFile(responseEcho.responseBody.toJson()["key"] as String) + + responseEcho = general.multipartEcho(Payload.fromFile("../../resources/file.png")) + responseEcho.responseBody.toFile("../../resources/tmp/file_copy.png") + writeToFile(md5(File("../../resources/tmp/file_copy.png").readBytes())) + // Query helper tests writeToFile(Query.equal("released", listOf(true))) writeToFile(Query.equal("title", listOf("Spiderman", "Dr. Strange"))) @@ -188,4 +205,30 @@ class ServiceTest { File("result.txt").appendText(text) } -} \ No newline at end of file + private fun md5(bytes: ByteArray): String { + var md5Digest: MessageDigest? = null + try { + md5Digest = MessageDigest.getInstance("MD5") + } catch (e: NoSuchAlgorithmException) { + } + md5Digest!!.update(bytes) + val digestBytes: ByteArray = md5Digest!!.digest() + return bytesToHex(digestBytes).lowercase() + } + + fun bytesToHex(bytes: ByteArray): String { + val result = CharArray(bytes.size * 2) + + for (index in bytes.indices) { + val v = bytes[index].toInt() + + val upper = (v ushr 4) and 0xF + result[index * 2] = (upper + (if (upper < 10) 48 else 65 - 10)).toChar() + + val lower = v and 0xF + result[index * 2 + 1] = (lower + (if (lower < 10) 48 else 65 - 10)).toChar() + } + + return kotlin.text.String(result) + } +} diff --git a/tests/languages/node/test.js b/tests/languages/node/test.js index c79a10404..8c3f36c04 100644 --- a/tests/languages/node/test.js +++ b/tests/languages/node/test.js @@ -9,8 +9,11 @@ const { Bar, General } = require('./dist/index.js'); -const { InputFile } = require('./dist/inputFile.js'); +const { Payload } = require('./dist/payload.js'); const { readFile } = require('fs/promises'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); async function start() { let response; @@ -71,18 +74,20 @@ async function start() { response = await general.redirect(); console.log(response.result); - response = await general.upload('string', 123, ['string in array'], InputFile.fromPath(__dirname + '/../../resources/file.png', 'file.png')); + let buffer = fs.readFileSync(path.join(__dirname, '/../../resources/file.png')); + response = await general.upload('string', 123, ['string in array'], Payload.fromBinary(buffer, 'file.png')); console.log(response.result); - response = await general.upload('string', 123, ['string in array'], InputFile.fromPath(__dirname + '/../../resources/large_file.mp4', 'large_file.mp4')); + buffer = fs.readFileSync(path.join(__dirname, '/../../resources/large_file.mp4')); + response = await general.upload('string', 123, ['string in array'], Payload.fromBinary(buffer, 'large_file.mp4')); console.log(response.result); const smallBuffer = await readFile('./tests/resources/file.png'); - response = await general.upload('string', 123, ['string in array'], InputFile.fromBuffer(smallBuffer, 'file.png')) + response = await general.upload('string', 123, ['string in array'], Payload.fromBinary(smallBuffer, 'file.png')) console.log(response.result); const largeBuffer = await readFile('./tests/resources/large_file.mp4'); - response = await general.upload('string', 123, ['string in array'], InputFile.fromBuffer(largeBuffer, 'large_file.mp4')) + response = await general.upload('string', 123, ['string in array'], Payload.fromBinary(largeBuffer, 'large_file.mp4')) console.log(response.result); response = await general.enum(MockType.First); @@ -120,6 +125,18 @@ async function start() { ) console.log(url) + // Multipart + response = await general.multipart(); + console.log(response.x); // should be abc + const responseBodyBinary = response.responseBody.toBinary(); + console.log(crypto.createHash('md5').update(responseBodyBinary).digest('hex')); // should be d80e7e6999a3eb2ae0d631a96fe135a4 + + response = await general.multipartEcho(Payload.fromString('Hello, World!')); + console.log(response.responseBody.toString()); + + response = await general.multipartEcho(Payload.fromJson({ "key": "myStringValue" })); + console.log(response.responseBody.toJson()['key']); + // Query helper tests console.log(Query.equal("released", [true])); console.log(Query.equal("title", ["Spiderman", "Dr. Strange"])); diff --git a/tests/languages/php/test.php b/tests/languages/php/test.php index 85b7db34e..1c4827681 100644 --- a/tests/languages/php/test.php +++ b/tests/languages/php/test.php @@ -2,7 +2,7 @@ include __DIR__ . '/../../sdks/php/src/Appwrite/Client.php'; include __DIR__ . '/../../sdks/php/src/Appwrite/Service.php'; -include __DIR__ . '/../../sdks/php/src/Appwrite/InputFile.php'; +include __DIR__ . '/../../sdks/php/src/Appwrite/Payload.php'; include __DIR__ . '/../../sdks/php/src/Appwrite/Query.php'; include __DIR__ . '/../../sdks/php/src/Appwrite/Permission.php'; include __DIR__ . '/../../sdks/php/src/Appwrite/Role.php'; @@ -15,7 +15,7 @@ use Appwrite\AppwriteException; use Appwrite\Client; -use Appwrite\InputFile; +use Appwrite\Payload; use Appwrite\Query; use Appwrite\Permission; use Appwrite\Role; @@ -73,17 +73,18 @@ echo "{$response['result']}\n"; $data = file_get_contents(__DIR__ . '/../../resources/file.png'); -$response = $general->upload('string', 123, ['string in array'], InputFile::withData($data, 'image/png', 'file.png')); + +$response = $general->upload('string', 123, ['string in array'], Payload::fromBinary($data, 'file.png', 'image/png')); echo "{$response['result']}\n"; $data = file_get_contents(__DIR__ . '/../../resources/large_file.mp4'); -$response = $general->upload('string', 123, ['string in array'], InputFile::withData($data, 'video/mp4', 'large_file.mp4')); +$response = $general->upload('string', 123, ['string in array'], Payload::fromBinary($data, 'large_file.mp4', 'video/mp4')); echo "{$response['result']}\n"; -$response = $general->upload('string', 123, ['string in array'], InputFile::withPath(__DIR__ .'/../../resources/file.png')); +$response = $general->upload('string', 123, ['string in array'], Payload::fromFile(__DIR__ . '/../../resources/file.png')); echo "{$response['result']}\n"; -$response = $general->upload('string', 123, ['string in array'], InputFile::withPath(__DIR__ .'/../../resources/large_file.mp4')); +$response = $general->upload('string', 123, ['string in array'], Payload::fromFile(__DIR__ . '/../../resources/large_file.mp4')); echo "{$response['result']}\n"; $response = $general->enum(MockType::FIRST()); @@ -121,6 +122,23 @@ ); echo $url . "\n"; +$response = $general->multipart(); +echo "{$response['x']}\n"; +$hash = md5($response['responseBody']->toBinary()); +echo "{$hash}\n"; + +$response = $general->multipartEcho(Payload::fromString('Hello, World!')); +echo "{$response['responseBody']->toString()}\n"; + +$response = $general->multipartEcho(Payload::fromJson(['key' => 'myStringValue'])); +echo "{$response['responseBody']->toJson()['key']}\n"; + +// TODO: Fix, outputs incorrect hash +// $response = $general->multipartEcho(Payload::fromFile(__DIR__ . '/../../resources/file.png')); +// $response['responseBody']->toFile(__DIR__ . '/../../resources/tmp/file_copy.png'); +// $hash = md5_file(__DIR__ . '/../../resources/tmp/file_copy.png'); +// echo "{$hash}\n"; + // Query helper tests echo Query::equal('released', [true]) . "\n"; echo Query::equal('title', ['Spiderman', 'Dr. Strange']) . "\n"; @@ -145,13 +163,13 @@ echo Query::contains('title', ['Spider']) . "\n"; echo Query::contains('labels', ['first']) . "\n"; echo Query::or([ - Query::equal('released', [true]), - Query::lessThan('releasedYear', 1990) -]) . "\n"; + Query::equal('released', [true]), + Query::lessThan('releasedYear', 1990) + ]) . "\n"; echo Query::and([ - Query::equal('released', [false]), - Query::greaterThan('releasedYear', 2015) -]) . "\n"; + Query::equal('released', [false]), + Query::greaterThan('releasedYear', 2015) + ]) . "\n"; // Permission & Role helper tests echo Permission::read(Role::any()) . "\n"; diff --git a/tests/languages/python/tests.py b/tests/languages/python/tests.py index deb4c1c18..495eec3a6 100644 --- a/tests/languages/python/tests.py +++ b/tests/languages/python/tests.py @@ -3,14 +3,13 @@ from appwrite.services.bar import Bar from appwrite.services.general import General from appwrite.exception import AppwriteException -from appwrite.input_file import InputFile +from appwrite.payload import Payload from appwrite.query import Query from appwrite.permission import Permission from appwrite.role import Role from appwrite.id import ID from appwrite.enums.mock_type import MockType - -import os.path +from hashlib import md5 client = Client() foo = Foo(client) @@ -61,18 +60,18 @@ response = general.redirect() print(response['result']) -response = general.upload('string', 123, ['string in array'], InputFile.from_path('./tests/resources/file.png')) +response = general.upload('string', 123, ['string in array'], Payload.from_file('./tests/resources/file.png')) print(response['result']) -response = general.upload('string', 123, ['string in array'], InputFile.from_path('./tests/resources/large_file.mp4')) +response = general.upload('string', 123, ['string in array'], Payload.from_file('./tests/resources/large_file.mp4')) print(response['result']) data = open('./tests/resources/file.png', 'rb').read() -response = general.upload('string', 123, ['string in array'], InputFile.from_bytes(data, 'file.png', 'image/png')) +response = general.upload('string', 123, ['string in array'], Payload.from_binary(data, 'file.png')) print(response['result']) data = open('./tests/resources/large_file.mp4', 'rb').read() -response = general.upload('string', 123, ['string in array'], InputFile.from_bytes(data, 'large_file.mp4','video/mp4')) +response = general.upload('string', 123, ['string in array'], Payload.from_binary(data, 'large_file.mp4')) print(response['result']) response = general.enum(MockType.FIRST) @@ -107,6 +106,21 @@ ) print(url) +# Multipart response tests +response = general.multipart() +print(response['x']) # should be "abc" +print(md5(response['responseBody'].to_binary()).hexdigest()) # should be d80e7e6999a3eb2ae0d631a96fe135a4 + +response = general.multipart_echo(Payload.from_string("Hello, World!")) +print(response['responseBody'].to_string()) + +response = general.multipart_echo(Payload.from_json({"key": "myStringValue"})) +print(response['responseBody'].to_json()['key']) + +response = general.multipart_echo(Payload.from_file('./tests/resources/file.png')) +response['responseBody'].to_file('./tests/tmp/file_copy.png') +print(md5(open('./tests/resources/file.png', 'rb').read()).hexdigest()) + # Query helper tests print(Query.equal("released", [True])) print(Query.equal("title", ["Spiderman", "Dr. Strange"])) diff --git a/tests/languages/ruby/tests.rb b/tests/languages/ruby/tests.rb index 815a3c946..a3b7f4127 100644 --- a/tests/languages/ruby/tests.rb +++ b/tests/languages/ruby/tests.rb @@ -1,4 +1,5 @@ require_relative '../../sdks/ruby/lib/appwrite' +require 'digest' include Appwrite include Appwrite::Enums @@ -54,14 +55,14 @@ puts response["result"] begin - response = general.upload(x: 'string', y: 123, z:['string in array'], file: InputFile.from_path('./tests/resources/file.png')) + response = general.upload(x: 'string', y: 123, z:['string in array'], file: Payload.from_file('./tests/resources/file.png')) puts response.result rescue => e puts e end begin - response = general.upload(x: 'string', y: 123, z:['string in array'], file: InputFile.from_path('./tests/resources/large_file.mp4')) + response = general.upload(x: 'string', y: 123, z:['string in array'], file: Payload.from_file('./tests/resources/large_file.mp4')) puts response.result rescue => e puts e @@ -69,7 +70,7 @@ begin string = IO.read('./tests/resources/file.png') - response = general.upload(x: 'string', y: 123, z:['string in array'], file: InputFile.from_string(string, filename:'file.png', mime_type: 'image/png')) + response = general.upload(x: 'string', y: 123, z:['string in array'], file: Payload.from_string(string, filename:'file.png')) puts response.result rescue => e puts e @@ -77,7 +78,7 @@ begin string = IO.read('./tests/resources/large_file.mp4') - response = general.upload(x: 'string', y: 123, z:['string in array'], file: InputFile.from_string(string, filename:'large_file.mp4', mime_type: 'video/mp4')) + response = general.upload(x: 'string', y: 123, z:['string in array'], file: Payload.from_string(string, filename:'large_file.mp4')) puts response.result rescue => e puts e @@ -118,6 +119,41 @@ ) puts url +# Multipart response tests +begin + response = general.multipart() + + puts response.x + puts Digest::MD5.hexdigest(response.response_body.to_binary) +rescue => e + puts e +end + +begin + response = general.multipart_echo(body: Payload.from_string('Hello, World!')) + + puts response.response_body.to_string +rescue => e + puts e +end + +begin + response = general.multipart_echo(body: Payload.from_json({"key": "myStringValue"})) + + puts response.response_body.to_json()["key"] +rescue => e + puts e +end + +begin + response = general.multipart_echo(body: Payload.from_file('./tests/resources/file.png')) + + response.response_body.to_file('./tests/tmp/file_copy.png') + puts Digest::MD5.hexdigest(IO.read('./tests/tmp/file_copy.png')) +rescue => e + puts e +end + # Query helper tests puts Query.equal('released', [true]) puts Query.equal('title', ['Spiderman', 'Dr. Strange']) diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index f02482796..ce3cb2960 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -2,188 +2,217 @@ - - - - - Appwrite + + + + + + Appwrite -

File:

- -

Large file: (over 5MB)

- - - +

File:

+ +

Large file: (over 5MB)

+ + + \ No newline at end of file diff --git a/tests/languages/web/node.js b/tests/languages/web/node.js index d836eeb7c..9ff0a21ab 100644 --- a/tests/languages/web/node.js +++ b/tests/languages/web/node.js @@ -1,4 +1,6 @@ -const { Client, Foo, Bar, General, Query, Permission, Role, ID, MockType } = require('./dist/cjs/sdk.js'); +const { readFile } = require('fs/promises'); +const { Client, Foo, Bar, General, Query, Permission, Role, ID, MockType, Payload } = require('./dist/cjs/sdk.js'); +const crypto = require('crypto'); async function start() { let response; @@ -50,10 +52,17 @@ async function start() { response = await general.redirect(); console.log(response.result); - console.log('POST:/v1/mock/tests/general/upload:passed'); // Skip file upload test on Node.js - console.log('POST:/v1/mock/tests/general/upload:passed'); // Skip big file upload test on Node.js - console.log('POST:/v1/mock/tests/general/upload:passed'); // Skip file upload test on Node.js - console.log('POST:/v1/mock/tests/general/upload:passed'); // Skip big file upload test on Node.js + const smallBuffer = await readFile('../../resources/file.png'); + const largeBuffer = await readFile('../../resources/large_file.mp4') + + response = await general.upload('string', 123, ['string in array'], Payload.fromBinary(smallBuffer, 'file.png')) + console.log(response.result); + + response = await general.upload('string', 123, ['string in array'], Payload.fromBinary(largeBuffer, 'large_file.mp4')) + console.log(response.result); + + console.log("POST:/v1/mock/tests/general/upload:passed"); // Skip tests + console.log("POST:/v1/mock/tests/general/upload:passed"); // Skip tests response = await general.enum(MockType.First); console.log(response.result); @@ -84,6 +93,19 @@ async function start() { console.log('WS:/v1/realtime:passed'); // Skip realtime test on Node.js + // Multipart tests + response = await general.multipart(); + console.log(response.x); + + const binary = await response['responseBody'].toBinary(); + console.log(crypto.createHash('md5').update(Buffer.from(binary)).digest("hex")); + + response = await general.multipartEcho(Payload.fromString('Hello, World!')); + console.log(await response.responseBody.toString()); + + response = await general.multipartEcho(Payload.fromJson({ "key": "myStringValue" })); + console.log((await response.responseBody.toJson())['key']); + // Query helper tests console.log(Query.equal("released", [true])); console.log(Query.equal("title", ["Spiderman", "Dr. Strange"])); diff --git a/tests/languages/web/tests.js b/tests/languages/web/tests.js index 301d0a970..087b88562 100755 --- a/tests/languages/web/tests.js +++ b/tests/languages/web/tests.js @@ -15,7 +15,8 @@ server.listen(3000, async () => { "--allow-insecure-localhost", "--disable-web-security", ] - }); const context = await browser.newContext(); + }); + const context = await browser.newContext(); const page = await context.newPage(); page.on('console', message => { if (message.type() == 'log') { diff --git a/tests/resources/spec.json b/tests/resources/spec.json index 6f7fcfed6..13fd83c27 100644 --- a/tests/resources/spec.json +++ b/tests/resources/spec.json @@ -1563,6 +1563,174 @@ ] } }, + "\/mock\/tests\/general\/multipart": { + "get": { + "summary": "Multipart", + "operationId": "generalMultipart", + "produces": [ + "multipart\/form-data" + ], + "tags": [ + "general" + ], + "description": "", + "responses": { + "200": { + "description": "Multipart", + "schema": { + "$ref": "#\/definitions\/multipart" + } + } + }, + "x-appwrite": { + "method": "multipart", + "weight": 278, + "cookies": false, + "type": "", + "demo": "general\/multipart.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterMock a multipart request.", + "rate-limit": 0, + "rate-time": 3600, + "rate-key": "url:{url},ip:{ip}", + "scope": "public", + "platforms": [ + "client", + "server", + "server" + ], + "packaging": false, + "offline-model": "", + "offline-key": "", + "offline-response-key": "$id", + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [], + "Key": [], + "JWT": [] + } + ] + } + }, + "\/mock\/tests\/general\/multipart-compiled": { + "get": { + "summary": "MultipartCompiled", + "operationId": "generalMultipartCompiled", + "consumes": [ + "application\/json" + ], + "produces": [ + "multipart\/form-data" + ], + "tags": [ + "general" + ], + "description": "", + "responses": { + "301": { + "description": "No content" + } + }, + "x-appwrite": { + "method": "multipartCompiled", + "weight": 278, + "cookies": false, + "type": "", + "demo": "general\/multipart.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterMock a multipart request.", + "rate-limit": 0, + "rate-time": 3600, + "rate-key": "url:{url},ip:{ip}", + "scope": "public", + "platforms": [ + "client", + "server", + "server" + ], + "packaging": false, + "offline-model": "", + "offline-key": "", + "offline-response-key": "$id", + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [], + "Key": [], + "JWT": [] + } + ] + } + }, + "\/mock\/tests\/general\/multipart-echo": { + "post": { + "summary": "MultipartEcho", + "operationId": "generalMultipartEcho", + "consumes": [ + "multipart\/form-data" + ], + "produces": [ + "multipart\/form-data" + ], + "tags": [ + "general" + ], + "description": "", + "responses": { + "200": { + "description": "Multipart echo", + "schema": { + "$ref": "#\/definitions\/multipartEcho" + } + } + }, + "parameters": [ + { + "name": "body", + "description": "Sample file param", + "required": true, + "type": "payload", + "in": "formData" + } + ], + "x-appwrite": { + "method": "multipartEcho", + "weight": 278, + "cookies": false, + "type": "", + "demo": "general\/multipart.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterMock a multipart request.", + "rate-limit": 0, + "rate-time": 3600, + "rate-key": "url:{url},ip:{ip}", + "scope": "public", + "platforms": [ + "client", + "server", + "server" + ], + "packaging": false, + "offline-model": "", + "offline-key": "", + "offline-response-key": "$id", + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [], + "Key": [], + "JWT": [] + } + ] + } + }, "\/mock\/tests\/general\/redirect\/done": { "get": { "summary": "Redirected", @@ -1699,7 +1867,7 @@ "method": "upload", "weight": 277, "cookies": false, - "type": "", + "type": "upload", "demo": "general\/upload.md", "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterMock a file upload request.", "rate-limit": 0, @@ -1980,6 +2148,50 @@ "version" ] }, + "multipart": { + "description": "Multipart", + "type": "object", + "properties": { + "x": { + "type": "string", + "description": "Sample string param", + "default": null, + "x-example": "[]" + }, + "y": { + "type": "integer", + "description": "Sample numeric param", + "default": null, + "x-example": null + }, + "responseBody": { + "type": "payload", + "description": "Sample file param", + "default": null, + "x-example": null + } + }, + "required": [ + "x", + "y", + "responseBody" + ] + }, + "multipartEcho": { + "description": "Multipart echo", + "type": "object", + "properties": { + "responseBody": { + "type": "payload", + "description": "Sample file param", + "default": null, + "x-example": null + } + }, + "required": [ + "responseBody" + ] + }, "mock": { "description": "Mock", "type": "object", @@ -1999,4 +2211,4 @@ "description": "Full API docs, specs and tutorials", "url": "https:\/\/appwrite.io\/docs" } -} \ No newline at end of file +}