diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 48dbd1f390..c0830e5774 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -316,19 +316,20 @@ ->inject('response') ->action(function (Request $request, UtopiaSwooleResponse $response) { - // Location methods render from their own request template, so they are the - // easiest place to lose the project header. The real API pairs an API key - // against this header and rejects the request without it. - if (empty($request->getHeader('x-appwrite-project', ''))) { + // Client SDKs build a URL for the browser, so credentials may arrive in + // the query string instead of headers. The real API accepts both. + $project = $request->getHeader('x-appwrite-project', '') ?: $request->getParam('project', ''); + if (empty($project)) { throw new Exception(Exception::GENERAL_MOCK, 'Missing project ID'); } + $impersonate = $request->getHeader('x-appwrite-impersonate-user-id', '') ?: $request->getParam('impersonateuserid', ''); $response ->setContentType('text/plain') ->addHeader('Content-Disposition', 'attachment; filename="test.txt"') ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)) . ' GMT') // 45 days cache ->addHeader('X-Peak', \memory_get_peak_usage()) - ->send("GET:/v1/mock/tests/general/download:passed"); + ->send('GET:/v1/mock/tests/general/download:passed' . ($impersonate === '' ? '' : ':as:' . $impersonate)); }); App::post('/v1/mock/tests/general/upload') diff --git a/src/SDK/SDK.php b/src/SDK/SDK.php index f02cc82c46..54df7da53a 100644 --- a/src/SDK/SDK.php +++ b/src/SDK/SDK.php @@ -185,6 +185,7 @@ public function __construct(protected Language $language, protected Specificatio $this->twig->addFilter(new TwigFilter('methodHeaders', fn(Operation $operation): array => $this->getMethodHeaders($operation))); $this->twig->addFilter(new TwigFilter('responseDiscriminator', fn(Operation $operation): array => $this->getResponseDiscriminator($operation))); $this->twig->addFilter(new TwigFilter('securitySchemes', fn(Operation $operation): array => $this->getOperationAuthSchemes($operation))); + $this->twig->addFilter(new TwigFilter('locationSchemes', fn(Operation $operation): array => $this->getOperationAuthSchemes($operation, true))); $this->twig->addFilter(new TwigFilter('securityHeaders', fn(Operation $operation): array => $this->getOperationSecuritySchemes($operation, ParameterLocation::HEADER, false))); $this->twig->addFilter(new TwigFilter('securityQueries', fn(Operation $operation): array => $this->getOperationSecuritySchemes($operation, ParameterLocation::QUERY))); $this->twig->addFilter(new TwigFilter('schemaNullable', fn(Schema|Parameter $value): bool => !isset($this->multipartSchemas[\spl_object_id($this->getSchema($value))]) && $this->getSchema($value)->nullable)); @@ -1850,7 +1851,14 @@ protected function getMethodHeaders(Operation $operation): array * * @return array */ - protected function getOperationAuthSchemes(Operation $operation): array + /** + * The schemes `x-appwrite.auth` configures for an operation. Examples skip + * the optional ones; a URL builder has no request to carry headers, so + * every scheme it lists becomes a query parameter and none may be dropped. + * + * @return array + */ + protected function getOperationAuthSchemes(Operation $operation, bool $includeOptional = false): array { $auth = $operation->extensions[Extension::APPWRITE->value][Appwrite::AUTH->value] ?? []; if (!\is_array($auth)) { @@ -1859,10 +1867,10 @@ protected function getOperationAuthSchemes(Operation $operation): array $auth = $auth[$this->getParam('platform')] ?? $auth; $schemes = []; $pathSchemes = []; - $optional = \array_diff($operation->acceptedSecuritySchemeNames(), $operation->requiredSecuritySchemeNames()); + $optional = $includeOptional + ? [] + : \array_diff($operation->acceptedSecuritySchemeNames(), $operation->requiredSecuritySchemeNames()); foreach (\array_keys($auth) as $name) { - // Example configuration is independent of API authentication. Only - // omit a candidate when security explicitly makes it optional. if (\in_array($name, $optional, true)) { continue; } diff --git a/templates/android/library/src/main/java/io/package/services/Service.kt.twig b/templates/android/library/src/main/java/io/package/services/Service.kt.twig index c9598b2f21..2b60605aff 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 @@ -97,8 +97,8 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, {%~ endfor %} {%~ if (method | methodType) == 'webAuth' %} - {%~ if [(method | securitySchemes)] | length > 0 %} - {%~ for node in [(method | securitySchemes)] %} + {%~ if [(method | locationSchemes)] | length > 0 %} + {%~ for node in [(method | locationSchemes)] %} {%~ for key,header in node | keys %} "{{ header | caseLower }}" to client.config["{{ header | caseLower }}"], {%~ endfor %} diff --git a/templates/dart/base/requests/oauth.twig b/templates/dart/base/requests/oauth.twig index 20443d2e96..fbf183116f 100644 --- a/templates/dart/base/requests/oauth.twig +++ b/templates/dart/base/requests/oauth.twig @@ -1,9 +1,9 @@ {% import 'dart/base/utils.twig' as utils %} {% set queryParams = method | parameters('query') %} {% set bodyParams = method | parameters('body') %} - final Map params = {% if queryParams|length == 0 and bodyParams|length == 0 and ([(method | securitySchemes)]|length == 0) %}{}{% else %}{ -{{ utils.map_parameter(queryParams) }}{{ utils.map_parameter(bodyParams) }}{% if [(method | securitySchemes)]|length > 0 %} -{% for node in [(method | securitySchemes)] %} + final Map params = {% if queryParams|length == 0 and bodyParams|length == 0 and ([(method | locationSchemes)]|length == 0) %}{}{% else %}{ +{{ utils.map_parameter(queryParams) }}{{ utils.map_parameter(bodyParams) }}{% if [(method | locationSchemes)]|length > 0 %} +{% for node in [(method | locationSchemes)] %} {% for key, header in node|keys %} '{{header|caseLower}}': client.config['{{header|caseLower}}'], {% endfor %} diff --git a/templates/dotnet/base/utils.twig b/templates/dotnet/base/utils.twig index cec7ba06b3..dce98b2009 100644 --- a/templates/dotnet/base/utils.twig +++ b/templates/dotnet/base/utils.twig @@ -9,7 +9,7 @@ {% if parameter.name == 'orderType' %}{{ parameter.name | caseCamel ~ '.ToString()'}}{% elseif (parameter | enumValues) is not empty and (parameter | schemaType) == 'array' %}{{ parameter.name | caseCamel | escapeKeyword }}?.Select(e => e.Value).ToList(){% elseif (parameter | enumValues) is not empty %}{{ parameter.name | caseCamel | escapeKeyword }}?.Value{% else %}{{ parameter.name | caseCamel | escapeKeyword }}{% endif %} {% endmacro %} {% macro methodNeedsSecurityParameters(method) %} -{% if ((method | methodType) == "webAuth" or (method | methodType) == "location") and [(method | securitySchemes)]|length > 0 %}{{ true }}{% else %}{{false}}{% endif %} +{% if ((method | methodType) == "webAuth" or (method | methodType) == "location") and [(method | locationSchemes)]|length > 0 %}{{ true }}{% else %}{{false}}{% endif %} {% endmacro %} {% macro resultType(namespace, method) %} {% if (method | methodType) == "webAuth" %}bool{% elseif (method | methodType) == "location" %}byte[]{% elseif (method | responseModels)|length > 1 %}object{% elseif not (method | responseModel) or (method | responseModel) == 'any' %}object{% else %}Models.{{(method | responseModel) | caseUcfirst | overrideIdentifier }}{% endif %} diff --git a/templates/flutter/base/requests/oauth.twig b/templates/flutter/base/requests/oauth.twig index 7007b7b3e4..011dec0096 100644 --- a/templates/flutter/base/requests/oauth.twig +++ b/templates/flutter/base/requests/oauth.twig @@ -1,9 +1,9 @@ {% import 'flutter/base/utils.twig' as utils %} {% set queryParams = method | parameters('query') %} {% set bodyParams = method | parameters('body') %} - final Map params = {% if queryParams|length == 0 and bodyParams|length == 0 and ([(method | securitySchemes)]|length == 0) %}{}{% else %}{ -{{ utils.map_parameter(queryParams) }}{{ utils.map_parameter(bodyParams) }}{% if [(method | securitySchemes)]|length > 0 %} -{% for node in [(method | securitySchemes)] %} + final Map params = {% if queryParams|length == 0 and bodyParams|length == 0 and ([(method | locationSchemes)]|length == 0) %}{}{% else %}{ +{{ utils.map_parameter(queryParams) }}{{ utils.map_parameter(bodyParams) }}{% if [(method | locationSchemes)]|length > 0 %} +{% for node in [(method | locationSchemes)] %} {% for key, header in node|keys %} '{{header|caseLower}}': client.config['{{header|caseLower}}'], {% endfor %} diff --git a/templates/react-native/src/services/template.ts.twig b/templates/react-native/src/services/template.ts.twig index 8da92025be..5536610d1c 100644 --- a/templates/react-native/src/services/template.ts.twig +++ b/templates/react-native/src/services/template.ts.twig @@ -165,8 +165,8 @@ export class {{ service.name | caseUcfirst }} extends Service { uri.searchParams.append('{{ security.name }}', this.client.config.{{ security.name | caseLower }}); {%~ endfor %} {% if (method | methodType) == 'location' or (method | methodType) == 'webAuth' %} -{% if [(method | securitySchemes)]|length > 0 %} -{% for node in [(method | securitySchemes)] %} +{% if [(method | locationSchemes)]|length > 0 %} +{% for node in [(method | locationSchemes)] %} {% for key,header in node|keys %} payload['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; @@ -484,7 +484,7 @@ export class {{ service.name | caseUcfirst }} extends Service { {%~ for security in (method | securityQueries) %} uri.searchParams.append('{{ security.name }}', this.client.config.{{ security.name | caseLower }}); {%~ endfor %} -{% for node in [(method | securitySchemes)] %} +{% for node in [(method | locationSchemes)] %} {% for key,header in node|keys %} payload['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; diff --git a/templates/swift/base/params.twig b/templates/swift/base/params.twig index fd64e8f472..547417d8b9 100644 --- a/templates/swift/base/params.twig +++ b/templates/swift/base/params.twig @@ -22,12 +22,12 @@ {%- else -%} let {%- endif %} apiParams: [String: Any?] = [ {%~ for parameter in (method | parameters('query')) | merge((method | parameters('body'))) %} - "{{ parameter.name }}": {{ parameter.name | caseCamel | escapeSwiftKeyword }}{% if (parameter | enumValues) is not empty %}{% if (parameter | schemaType) == 'array' %}{% if not parameter.required %}?{% endif %}.map { $0.rawValue }{% else %}{% if not parameter.required %}?{% endif %}.rawValue{% endif %}{% endif %}{% if not loop.last or ((method | parameters('query')) | merge((method | parameters('body')))) | length > 1 or ((method | methodType) == 'webAuth' and [(method | securitySchemes)] | length > 0) %},{% endif %} + "{{ parameter.name }}": {{ parameter.name | caseCamel | escapeSwiftKeyword }}{% if (parameter | enumValues) is not empty %}{% if (parameter | schemaType) == 'array' %}{% if not parameter.required %}?{% endif %}.map { $0.rawValue }{% else %}{% if not parameter.required %}?{% endif %}.rawValue{% endif %}{% endif %}{% if not loop.last or ((method | parameters('query')) | merge((method | parameters('body')))) | length > 1 or ((method | methodType) == 'webAuth' and [(method | locationSchemes)] | length > 0) %},{% endif %} {%~ endfor %} {%~ if (method | methodType) == 'webAuth' %} - {%~ if [(method | securitySchemes)] | length > 0 %} - {%~ for node in [(method | securitySchemes)] %} + {%~ if [(method | locationSchemes)] | length > 0 %} + {%~ for node in [(method | locationSchemes)] %} {%~ for key,header in node | keys %} "{{ header | caseLower }}": client.config["{{ header | caseLower }}"]{% if not loop.last or ((method | parameters('query')) | merge((method | parameters('body')))) | length > 0 or (node | keys) | length > 1 %},{% endif %} diff --git a/templates/web/src/services/template.ts.twig b/templates/web/src/services/template.ts.twig index faa3231a0c..3c5da4fe23 100644 --- a/templates/web/src/services/template.ts.twig +++ b/templates/web/src/services/template.ts.twig @@ -174,8 +174,8 @@ export class {{ service.name | caseUcfirst }} { {%~ endif %} {%~ if (method | methodType) == 'location' or (method | methodType) == 'webAuth' %} - {%~ if [(method | securitySchemes)]|length > 0 %} - {%~ for node in [(method | securitySchemes)] %} + {%~ if [(method | locationSchemes)]|length > 0 %} + {%~ for node in [(method | locationSchemes)] %} {%~ for key,header in node|keys %} payload['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; {%~ endfor %} diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index a56dcbb65c..692296c4d9 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -45,6 +45,11 @@ abstract class Base extends TestCase 'GET:/v1/mock/tests/general/redirect/done:passed', ]; + protected const LOCATION_RESPONSES = [ + 'GET:/v1/mock/tests/general/download:passed', + 'GET:/v1/mock/tests/general/download:passed:as:impersonated', + ]; + protected const PATH_PARAM_RESPONSES = [ 'GET:/v1/mock/tests/general/path/grant%2Fspecial%26id:passed', ]; diff --git a/tests/e2e/WebChromiumTest.php b/tests/e2e/WebChromiumTest.php index 3b87428f09..f347cff96a 100644 --- a/tests/e2e/WebChromiumTest.php +++ b/tests/e2e/WebChromiumTest.php @@ -41,6 +41,7 @@ final class WebChromiumTest extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::LOCATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::UPLOAD_RESPONSE, diff --git a/tests/e2e/WebNodeTest.php b/tests/e2e/WebNodeTest.php index 3440c51b5a..69332da8d0 100644 --- a/tests/e2e/WebNodeTest.php +++ b/tests/e2e/WebNodeTest.php @@ -42,6 +42,7 @@ final class WebNodeTest extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::LOCATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::ENUM_RESPONSES, diff --git a/tests/e2e/languages/web/index.html b/tests/e2e/languages/web/index.html index ebcc1ff538..cd61085d54 100644 --- a/tests/e2e/languages/web/index.html +++ b/tests/e2e/languages/web/index.html @@ -195,6 +195,14 @@ response = await general.redirect(); console.log(response.result); + // Location URL builder carries the configured credentials in the query string + response = await fetch(general.download()); + console.log(await response.text()); + client.setImpersonateUserId('impersonated'); + response = await fetch(general.download()); + console.log(await response.text()); + client.setImpersonateUserId(''); + for (const [id, plain] of [['', '0'], ['0', '']]) { try { await general.validatePath({ id, plain }); diff --git a/tests/e2e/languages/web/node.js b/tests/e2e/languages/web/node.js index b8b3b30a15..f140257b8f 100644 --- a/tests/e2e/languages/web/node.js +++ b/tests/e2e/languages/web/node.js @@ -124,6 +124,14 @@ async function start() { response = await general.redirect(); console.log(response.result); + // Location URL builder carries the configured credentials in the query string + response = await fetch(general.download()); + console.log(await response.text()); + client.setImpersonateUserId('impersonated'); + response = await fetch(general.download()); + console.log(await response.text()); + client.setImpersonateUserId(''); + for (const [id, plain] of [['', '0'], ['0', '']]) { try { await general.validatePath({ id, plain }); diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index fcd982e8ca..41e0d394ab 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -297,7 +297,8 @@ public function testFixtureSelection(string $name): void * A canonical document keys `x-appwrite.auth` by platform. The fixture's * platform-auth operation lists `Project` for client and `Project, Key` for * server plus an optional Session, and the server alias variant lists - * `Project, JWT`. Optional security must not become example configuration. + * `Project, JWT`. Optional security must not become example configuration; + * the Web E2E proves it still reaches a location method's query string. */ public function testExampleCredentialsFollowPlatformAuth(): void { @@ -306,6 +307,7 @@ public function testExampleCredentialsFollowPlatformAuth(): void ['client', 'docs/examples/general/zzderivedauth.md', ['->setproject('], ['->setkey(', '->setjwt(', '->setsession(']], ['server', 'docs/examples/general/zzplatformalias.md', ['->setproject(', '->setjwt('], ['->setkey(', '->setsession(']], ['client', 'docs/examples/general/zzplatformalias.md', ['->setproject('], ['->setkey(', '->setjwt(', '->setsession(']], + ['client', 'docs/examples/general/download.md', ['->setproject('], ['->setimpersonateuserid(']], ]; foreach ($examples as [$platform, $path, $present, $absent]) { diff --git a/tests/resources/spec-openapi3.json b/tests/resources/spec-openapi3.json index 057f47bfe6..956ef9c675 100644 --- a/tests/resources/spec-openapi3.json +++ b/tests/resources/spec-openapi3.json @@ -1288,6 +1288,12 @@ "Project": [], "Key": [], "JWT": [] + }, + { + "Project": [], + "Key": [], + "JWT": [], + "ImpersonateUserId": [] } ], "responses": {