Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions mock-server/app/http.php
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,10 @@
throw new Exception(Exception::GENERAL_MOCK, 'Invalid content-range header');
}

if ($start === 0 && $end === $size - 1 && $size === $chunkSize) {
throw new Exception(Exception::GENERAL_MOCK, 'A file of exactly the chunk size must be sent in one request');
}

if ($start === 0 && !empty($id)) {
throw new Exception(Exception::GENERAL_MOCK, 'First chunked request cannot have id header');
}
Expand Down Expand Up @@ -460,6 +464,14 @@
$file['name'] = (\is_array($file['name'])) ? $file['name'][0] : $file['name'];
$file['size'] = (\is_array($file['size'])) ? $file['size'][0] : $file['size'];

if ($file['name'] === 'boundary.bin') {
if ($file['size'] !== $chunkSize) {
throw new Exception(Exception::GENERAL_MOCK, 'Wrong boundary file size');
}

return;
}

if ($file['name'] !== 'file.png') {
throw new Exception(Exception::GENERAL_MOCK, 'Wrong file name');
}
Expand Down
13 changes: 9 additions & 4 deletions src/SDK/Language/Dart.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,18 +211,23 @@ public function getParamExample(Schema|Parameter $param, string $lang = ''): str
}

return match ($type) {
self::TYPE_ARRAY => $this->isPermissionString($example) ? $this->getPermissionExample($example) : $example,
self::TYPE_ARRAY => $this->isPermissionString($example) ? $this->getPermissionExample($example) : $this->escapeInterpolation((string) $example),
self::TYPE_FILE, self::TYPE_INTEGER, self::TYPE_NUMBER => $example,
self::TYPE_BOOLEAN => ($example) ? 'true' : 'false',
self::TYPE_OBJECT => ($decoded = json_decode((string) $example, true)) !== null
? (empty($decoded) && $example === '{}'
? '{}'
: preg_replace('/\n/', "\n ", json_encode($decoded, JSON_PRETTY_PRINT)))
: $example,
self::TYPE_STRING => "'{$example}'",
: $this->escapeInterpolation((string) preg_replace('/\n/', "\n ", json_encode($decoded, JSON_PRETTY_PRINT))))
: $this->escapeInterpolation((string) $example),
self::TYPE_STRING => "'" . $this->escapeInterpolation((string) $example) . "'",
};
}

private function escapeInterpolation(string $value): string
{
return str_replace('$', '\$', str_replace('\$', '$', $value));
}

public function getModelToMapValue(Schema $property, string $propertyName, bool $required): string
{
$name = $this->escapeKeyword($propertyName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ class Client @JvmOverloads constructor(
else -> throw UnsupportedOperationException()
}

if (size < CHUNK_SIZE || responseType == String::class.java) {
if (size <= CHUNK_SIZE || responseType == String::class.java) {
val data = when (input.sourceType) {
"file", "path" -> File(input.path).asRequestBody()
"bytes" -> (input.data as ByteArray).toRequestBody(input.mimeType.toMediaType())
Expand Down
2 changes: 1 addition & 1 deletion templates/apple/Sources/Client.swift.twig
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ open class Client {

let size = (input.data as! ByteBuffer).readableBytes

if size < Client.chunkSize || T.self == String.self {
if size <= Client.chunkSize || T.self == String.self {
Comment thread
hansi-codes[bot] marked this conversation as resolved.
params[paramName] = input
return try await call(
method: method,
Expand Down
52 changes: 41 additions & 11 deletions templates/apple/Sources/Services/Push.swift.twig
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ open class Push: Service {
private var connectionEpoch = 0
private var resubscribeOnConnect = false
private var retired = Set<ObjectIdentifier>()
private var closedClients = Set<ObjectIdentifier>()
private var closeGeneration = 0

private var onOpenCb: (() -> Void)?
private var onCloseCb: (() -> Void)?
Expand Down Expand Up @@ -252,10 +254,14 @@ open class Push: Service {
requestNotificationAuthorization()
}

let generation = sync.sync { closeGeneration }
let mqtt: MQTTClient
do {
mqtt = try await connect()
} catch is SupersededError {
if sync.sync(execute: { closeGeneration != generation }) {
throw Push.closedError()
}
return try await subscribe(topics.map { $0 as TopicValue }, background: background, title: title, retry: retry, callback: callback)
}
let epoch = sync.sync { connectionEpoch }
Expand All @@ -281,6 +287,9 @@ open class Push: Service {
return true
}
if superseded {
if sync.sync(execute: { closeGeneration != generation }) {
throw Push.closedError()
}
return try await subscribe(topics.map { $0 as TopicValue }, background: background, title: title, retry: retry, callback: callback)
}
// The SUBSCRIBE failed; roll this call's registrations back (and drop any broker
Expand Down Expand Up @@ -388,32 +397,49 @@ open class Push: Service {
}

/// Tear down the connection and drop all active subscriptions.
///
/// Returns at once: the DISCONNECT and the client shutdown run on a background queue,
/// because a blocking wait is not allowed on the event loop that runs message callbacks,
/// and those callbacks may call `close()` or drop the last subscription. A connection
/// still being opened is abandoned, and any `subscribe` waiting on it throws.
public func close() {
let client = sync.sync { () -> MQTTClient? in
// Mark this a deliberate close so the close listener does not reconnect.
closing = true
connectionEpoch += 1
closeGeneration += 1
let existing = mqtt
mqtt = nil
connecting?.cancel()
connecting = nil
resubscribeOnConnect = false
subscriptions.removeAll()
if let existing {
closedClients.insert(ObjectIdentifier(existing))
}
return existing
}
guard let client else { return }
do {
try client.disconnect().wait()
} catch MQTTError.noConnection {
// Already disconnected (e.g. the broker dropped the connection); nothing to send.
} catch {
surface(Push.describe(error))
}
do {
try client.syncShutdownGracefully()
} catch {
surface(error)
DispatchQueue.global().async { [weak self] in
do {
try client.disconnect().wait()
} catch MQTTError.noConnection {
// Already disconnected (e.g. the broker dropped the connection); nothing to send.
} catch {
self?.surface(Push.describe(error))
}
do {
try client.syncShutdownGracefully()
} catch {
self?.surface(error)
}
}
}

private static func closedError() -> Swift.Error {
{{ spec.info.title | caseUcfirst }}Error(message: "Push was closed before the subscription was established")
}

private func requestNotificationAuthorization() {
#if canImport(UserNotifications)
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] _, error in
Expand Down Expand Up @@ -605,6 +631,10 @@ open class Push: Service {
if let self, let mqtt, self.sync.sync(execute: { self.retired.remove(ObjectIdentifier(mqtt)) != nil }) {
return
}
if let self, let mqtt, self.sync.sync(execute: { self.closedClients.remove(ObjectIdentifier(mqtt)) != nil }) {
self.onCloseCb?()
return
}
if case .failure(let error) = result {
self?.surface(Push.describe(error))
} else if let self, !self.sync.sync(execute: { self.closing }) {
Expand Down
5 changes: 4 additions & 1 deletion templates/dart/docs/example.md.twig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import 'dart:io';
{% endif %}
import 'package:{{ language.params.packageName }}/{{ language.params.packageName }}.dart';
{% if method.method.value != 'delete' and (method | methodType) not in ['webAuth', 'location'] and (method | getValidResponseModels)|length <= 1 and (method | responseModel) %}
import 'package:{{ language.params.packageName }}/models.dart' as models;
{% endif %}
{% set hasEnumParam = false %}
{% for parameter in (method | parameters('all')) %}
{% if (parameter | enumValues) | length > 0 %}
Expand All @@ -29,7 +32,7 @@ Client client = Client()

{{ service.name | caseUcfirst }} {{ service.name | caseCamel }} = {{service.name | caseUcfirst}}(client);

{% if method.method.value != 'delete' and (method | methodType) != 'webAuth' %}{% if (method | methodType) == 'location' %}Uint8List{% elseif (method | getValidResponseModels)|length > 1 %}dynamic{% else %}{{ (method | responseModel) | caseUcfirst | overrideIdentifier }}{% endif %} result = {% endif %}await {{ service.name | caseCamel }}.{{ (method | methodName) | caseCamel }}({% if (method | parameters('all')) | length == 0 %});{% endif %}
{% if method.method.value != 'delete' and (method | methodType) != 'webAuth' %}{% if (method | methodType) == 'location' %}Uint8List{% elseif (method | getValidResponseModels)|length > 1 %}dynamic{% else %}models.{{ (method | responseModel) | caseUcfirst | overrideIdentifier }}{% endif %} result = {% endif %}await {{ service.name | caseCamel }}.{{ (method | methodName) | caseCamel }}({% if (method | parameters('all')) | length == 0 %});{% endif %}

{%~ for parameter in (method | parameters('all')) %}
{{ parameter.name | caseCamel | overrideIdentifier }}: {% if (parameter | enumValues) | length > 0 %}{{ parameter | enumExample }}{% else %}{{ parameter | paramExample | replace({'<field-name>': (parameter.name | caseCamel) }) | raw }}{% endif %},{% if not parameter.required %} // (optional){% endif %}
Expand Down
2 changes: 1 addition & 1 deletion templates/dotnet/Package/Client.cs.twig
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ namespace {{ spec.info.title | caseUcfirst }}
var offset = 0L;
var result = new Dictionary<string, object?>();

if (size < ChunkSize || typeof(T) == typeof(string))
if (size <= ChunkSize || typeof(T) == typeof(string))
{
byte[] buffer;
switch (input.SourceType)
Expand Down
19 changes: 16 additions & 3 deletions templates/flutter/README.md.twig
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ flutter pub add {{ language.params.packageName }}

### Push on Android

`Push` includes a native Android plugin, and `flutter_local_notifications` needs core library
desugaring, so add this to `android/app/build.gradle.kts` (desugaring is configured when the app
is assembled, so a plugin cannot apply it for you):
`Push` includes a native Android plugin, so the SDK needs Android 7.0 (API 24) or later: apps
with a lower `minSdk` fail to build until they raise it. `flutter_local_notifications` also needs
core library desugaring. Add both to `android/app/build.gradle.kts` (desugaring is configured when
the app is assembled, so a plugin cannot apply it for you):

```kotlin
android {
defaultConfig {
minSdk = 24
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
Expand All @@ -61,7 +65,16 @@ seconds to reconnect; the broker replays what was sent in between (`retry: true`
in-app callback receives are posted as notifications that open the app. It reconnects with the
credential saved at subscribe time, so use a session rather than a short-lived JWT.

On Android 13 and later, notifications only appear once the user grants the
`POST_NOTIFICATIONS` runtime permission. Without it, the subscription still delivers to your
callback but posts no notification, so ask before subscribing, for example with
`flutter_local_notifications`:

```dart
await FlutterLocalNotificationsPlugin()
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();

final sub = await push.subscribe('news', (message) => print(message.data),
background: true, title: 'News');

Expand Down
5 changes: 4 additions & 1 deletion templates/flutter/docs/example.md.twig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
{% endif %}
import 'package:{{ language.params.packageName }}/{{ language.params.packageName }}.dart';
{% if method.method.value != 'delete' and (method | methodType) not in ['webAuth', 'location'] and (method | getValidResponseModels)|length <= 1 and (method | responseModel) %}
import 'package:{{ language.params.packageName }}/models.dart' as models;
{% endif %}
{% set hasEnumParam = false %}
{% for parameter in (method | parameters('all')) %}
{% if (parameter | enumValues) | length > 0 %}
Expand Down Expand Up @@ -59,7 +62,7 @@ FutureBuilder<Uint8List>(
},
);
{% else %}
{% if method.method.value != 'delete' and (method | methodType) != 'webAuth' %}{% if (method | methodType) == 'location' %}Uint8List{% elseif (method | getValidResponseModels)|length > 1 %}dynamic{% else %}{{ (method | responseModel) | caseUcfirst | overrideIdentifier }}{% endif %} result = {% endif %}await {{ service.name | caseCamel }}.{{ (method | methodName) | caseCamel }}({% if (method | parameters('all')) | length == 0 %});{% endif %}
{% if method.method.value != 'delete' and (method | methodType) != 'webAuth' %}{% if (method | methodType) == 'location' %}Uint8List{% elseif (method | getValidResponseModels)|length > 1 %}dynamic{% else %}models.{{ (method | responseModel) | caseUcfirst | overrideIdentifier }}{% endif %} result = {% endif %}await {{ service.name | caseCamel }}.{{ (method | methodName) | caseCamel }}({% if (method | parameters('all')) | length == 0 %});{% endif %}

{%~ for parameter in (method | parameters('all')) %}
{{ parameter.name | caseCamel | overrideIdentifier}}: {% if (parameter | enumValues) | length > 0%}{{ parameter | enumExample }}{% else %}{{ parameter | paramExample | replace({'<field-name>': (parameter.name | caseCamel) }) | raw }}{% endif %},{% if not parameter.required %} // optional{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ class Client @JvmOverloads constructor(
else -> throw UnsupportedOperationException()
}

if (size < CHUNK_SIZE || responseType == String::class.java) {
if (size <= CHUNK_SIZE || responseType == String::class.java) {
val data = when (input.sourceType) {
"file", "path" -> File(input.path).asRequestBody()
"bytes" -> (input.data as ByteArray).toRequestBody(input.mimeType.toMediaType())
Expand Down
2 changes: 1 addition & 1 deletion templates/node/docs/example.md.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
```javascript
const sdk = require('node-{{ spec.info.title | caseDash }}');
{% if (method | consumes)[0] == 'multipart/form-data' %}
const fs = require('fs');
const { InputFile } = require('node-{{ spec.info.title | caseDash }}/file');
{% endif %}

{% set clientCalls = [] %}{% if [(method | securitySchemes)]|length > 0 %}{% set clientCalls = clientCalls|merge([{'call': 'setEndpoint', 'argument': endpointDocs, 'comment': 'Your API Endpoint'}]) %}{% for node in [(method | securitySchemes)] %}{% for key,header in node|keys %}{% set clientCalls = clientCalls|merge([{'call': ('set' ~ header), 'argument': node[header].extensions['x-appwrite']['demo'], 'comment': node[header].description}]) %}{% endfor %}{% endfor %}{% endif %}
Expand Down
110 changes: 110 additions & 0 deletions templates/node/test/client.test.js.twig
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { Client } = require('../dist/client');
const { InputFile } = require('../dist/inputFile');
const { fetch: mockedFetch, Response, Dispatcher } = require('undici');

jest.mock('undici', () => ({
Expand Down Expand Up @@ -43,3 +44,112 @@ describe('Client', () => {
}
});
});

describe('Client.chunkedUpload', () => {
const url = new URL(
'https://cloud.appwrite.io/v1/storage/buckets/bucket/files',
);
const headers = { 'content-type': 'multipart/form-data' };
const sentHeaders = (index) => mockedFetch.mock.calls[index][1].headers;
const file = (size) =>
InputFile.fromBuffer(Buffer.alloc(size), 'upload.bin');

beforeEach(() => {
mockedFetch.mockReset();
});

test('sends a file of exactly the chunk size in one request', async () => {
mockedFetch.mockImplementation(() => Response.json({ $id: 'file' }));
const client = new Client();

await client.chunkedUpload(
'POST',
url,
{ ...headers },
{ file: file(Client.CHUNK_SIZE) },
() => {},
);

expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(sentHeaders(0)).not.toHaveProperty('content-range');
});

test('splits a larger file into chunks that share the upload id', async () => {
mockedFetch.mockImplementation(() =>
Response.json({
$id: 'file',
chunksUploaded: mockedFetch.mock.calls.length,
chunksTotal: 2,
}),
);
const client = new Client();
const size = Client.CHUNK_SIZE + 1;

const result = await client.chunkedUpload(
'POST',
url,
{ ...headers },
{ file: file(size) },
() => {},
);

expect(mockedFetch).toHaveBeenCalledTimes(2);
expect(sentHeaders(0)['content-range']).toBe(
`bytes 0-${Client.CHUNK_SIZE - 1}/${size}`,
);
expect(sentHeaders(1)['content-range']).toBe(
`bytes ${Client.CHUNK_SIZE}-${Client.CHUNK_SIZE}/${size}`,
);
expect(sentHeaders(1)['x-{{ spec.info.title | caseLower }}-id']).toBe('file');
expect(result).toEqual({
$id: 'file',
chunksUploaded: 2,
chunksTotal: 2,
});
});

test('sends a text upload in one request whatever its size', async () => {
mockedFetch.mockImplementation(
() =>
new Response('zone file', {
headers: { 'content-type': 'text/plain' },
}),
);
const client = new Client();

const result = await client.chunkedUpload(
'POST',
url,
{ ...headers },
{ file: file(Client.CHUNK_SIZE * 2 + 1) },
() => {},
'text',
);

expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(sentHeaders(0)).not.toHaveProperty('content-range');
expect(result).toBe('zone file');
});

test('makes a plain call with the requested response type when no file is sent', async () => {
mockedFetch.mockImplementation(
() =>
new Response('plain text', {
headers: { 'content-type': 'text/plain' },
}),
);
const client = new Client();

const result = await client.chunkedUpload(
'POST',
url,
{ ...headers },
{ name: 'zone' },
() => {},
'text',
);

expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(result).toBe('plain text');
});
});
Loading
Loading