diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart new file mode 100644 index 0000000000..7b27f59aee --- /dev/null +++ b/mobile/lib/utils/openapi_patching.dart @@ -0,0 +1,12 @@ +import 'package:openapi/api.dart'; + +dynamic upgradeDto(dynamic value, String targetType) { + switch (targetType) { + case 'UserPreferencesResponseDto': + if (value is Map) { + if (value['rating'] == null) { + value['rating'] = RatingResponse().toJson(); + } + } + } +} diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 19ff7fc6d5..bbe680731e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -16,6 +16,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/utils/openapi_patching.dart'; import 'package:http/http.dart'; import 'package:intl/intl.dart'; import 'package:meta/meta.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 346eee3f50..01c646d393 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -166,6 +166,7 @@ class ApiClient { /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { + upgradeDto(value, targetType); try { switch (targetType) { case 'String': diff --git a/mobile/openapi/lib/model/rating_response.dart b/mobile/openapi/lib/model/rating_response.dart index 80ef5980fb..31505550ef 100644 --- a/mobile/openapi/lib/model/rating_response.dart +++ b/mobile/openapi/lib/model/rating_response.dart @@ -13,7 +13,7 @@ part of openapi.api; class RatingResponse { /// Returns a new [RatingResponse] instance. RatingResponse({ - required this.enabled, + this.enabled = false, }); bool enabled; diff --git a/mobile/openapi/pubspec.yaml b/mobile/openapi/pubspec.yaml index f033028432..4a979bf5db 100644 --- a/mobile/openapi/pubspec.yaml +++ b/mobile/openapi/pubspec.yaml @@ -13,5 +13,5 @@ dependencies: http: '>=0.13.0 <0.14.0' intl: any meta: '^1.1.8' -dev_dependencies: - test: '>=1.21.6 <1.22.0' + immich_mobile: + path: ../ diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index a00d57d0ae..bf79b0bd82 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -8,12 +8,18 @@ function dart { cd ./templates/mobile/serialization/native wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache =0.13.0 <0.14.0' + intl: any + meta: '^1.1.8' +-dev_dependencies: +- test: '>=1.21.6 <1.22.0' ++ immich_mobile: ++ path: ../ diff --git a/open-api/templates/mobile/api_client.mustache b/open-api/templates/mobile/api_client.mustache new file mode 100644 index 0000000000..7f464f026e --- /dev/null +++ b/open-api/templates/mobile/api_client.mustache @@ -0,0 +1,264 @@ +{{>header}} +{{>part_of}} +class ApiClient { + ApiClient({this.basePath = '{{{basePath}}}', this.authentication,}); + + final String basePath; + final Authentication? authentication; + + var _client = Client(); + final _defaultHeaderMap = {}; + + /// Returns the current HTTP [Client] instance to use in this class. + /// + /// The return value is guaranteed to never be null. + Client get client => _client; + + /// Requests to use a new HTTP [Client] in this class. + set client(Client newClient) { + _client = newClient; + } + + Map get defaultHeaderMap => _defaultHeaderMap; + + void addDefaultHeader(String key, String value) { + _defaultHeaderMap[key] = value; + } + + // We don't use a Map for queryParams. + // If collectionFormat is 'multi', a key might appear multiple times. + Future invokeAPI( + String path, + String method, + List queryParams, + Object? body, + Map headerParams, + Map formParams, + String? contentType, + ) async { + await authentication?.applyToParams(queryParams, headerParams); + + headerParams.addAll(_defaultHeaderMap); + if (contentType != null) { + headerParams['Content-Type'] = contentType; + } + + final urlEncodedQueryParams = queryParams.map((param) => '$param'); + final queryString = urlEncodedQueryParams.isNotEmpty ? '?${urlEncodedQueryParams.join('&')}' : ''; + final uri = Uri.parse('$basePath$path$queryString'); + + try { + // Special case for uploading a single file which isn't a 'multipart/form-data'. + if ( + body is MultipartFile && (contentType == null || + !contentType.toLowerCase().startsWith('multipart/form-data')) + ) { + final request = StreamedRequest(method, uri); + request.headers.addAll(headerParams); + request.contentLength = body.length; + body.finalize().listen( + request.sink.add, + onDone: request.sink.close, + // ignore: avoid_types_on_closure_parameters + onError: (Object error, StackTrace trace) => request.sink.close(), + cancelOnError: true, + ); + final response = await _client.send(request); + return Response.fromStream(response); + } + + if (body is MultipartRequest) { + final request = MultipartRequest(method, uri); + request.fields.addAll(body.fields); + request.files.addAll(body.files); + request.headers.addAll(body.headers); + request.headers.addAll(headerParams); + final response = await _client.send(request); + return Response.fromStream(response); + } + + final msgBody = contentType == 'application/x-www-form-urlencoded' + ? formParams + : await serializeAsync(body); + final nullableHeaderParams = headerParams.isEmpty ? null : headerParams; + + switch(method) { + case 'POST': return await _client.post(uri, headers: nullableHeaderParams, body: msgBody,); + case 'PUT': return await _client.put(uri, headers: nullableHeaderParams, body: msgBody,); + case 'DELETE': return await _client.delete(uri, headers: nullableHeaderParams, body: msgBody,); + case 'PATCH': return await _client.patch(uri, headers: nullableHeaderParams, body: msgBody,); + case 'HEAD': return await _client.head(uri, headers: nullableHeaderParams,); + case 'GET': return await _client.get(uri, headers: nullableHeaderParams,); + } + } on SocketException catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'Socket operation failed: $method $path', + error, + trace, + ); + } on TlsException catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'TLS/SSL communication failed: $method $path', + error, + trace, + ); + } on IOException catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'I/O operation failed: $method $path', + error, + trace, + ); + } on ClientException catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'HTTP connection failed: $method $path', + error, + trace, + ); + } on Exception catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'Exception occurred: $method $path', + error, + trace, + ); + } + + throw ApiException( + HttpStatus.badRequest, + 'Invalid HTTP operation: $method $path', + ); + } +{{#native_serialization}} + + Future deserializeAsync(String value, String targetType, {bool growable = false,}) async => + // ignore: deprecated_member_use_from_same_package + deserialize(value, targetType, growable: growable); + + @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use deserializeAsync() instead.') + dynamic deserialize(String value, String targetType, {bool growable = false,}) { + // Remove all spaces. Necessary for regular expressions as well. + targetType = targetType.replaceAll(' ', ''); // ignore: parameter_assignments + + // If the expected target type is String, nothing to do... + return targetType == 'String' + ? value + : fromJson(json.decode(value), targetType, growable: growable); + } +{{/native_serialization}} + + // ignore: deprecated_member_use_from_same_package + Future serializeAsync(Object? value) async => serialize(value); + + @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use serializeAsync() instead.') + String serialize(Object? value) => value == null ? '' : json.encode(value); + +{{#native_serialization}} + /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. + static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { + upgradeDto(value, targetType); + try { + switch (targetType) { + case 'String': + return value is String ? value : value.toString(); + case 'int': + return value is int ? value : int.parse('$value'); + case 'double': + return value is double ? value : double.parse('$value'); + case 'bool': + if (value is bool) { + return value; + } + final valueString = '$value'.toLowerCase(); + return valueString == 'true' || valueString == '1'; + case 'DateTime': + return value is DateTime ? value : DateTime.tryParse(value); + {{#models}} + {{#model}} + case '{{{classname}}}': + {{#isEnum}} + {{#native_serialization}}return {{{classname}}}TypeTransformer().decode(value);{{/native_serialization}} + {{/isEnum}} + {{^isEnum}} + return {{{classname}}}.fromJson(value); + {{/isEnum}} + {{/model}} + {{/models}} + default: + dynamic match; + if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { + return value + .map((dynamic v) => fromJson(v, match, growable: growable,)) + .toList(growable: growable); + } + if (value is Set && (match = _regSet.firstMatch(targetType)?.group(1)) != null) { + return value + .map((dynamic v) => fromJson(v, match, growable: growable,)) + .toSet(); + } + if (value is Map && (match = _regMap.firstMatch(targetType)?.group(1)) != null) { + return Map.fromIterables( + value.keys.cast(), + value.values.map((dynamic v) => fromJson(v, match, growable: growable,)), + ); + } + } + } on Exception catch (error, trace) { + throw ApiException.withInner(HttpStatus.internalServerError, 'Exception during deserialization.', error, trace,); + } + throw ApiException(HttpStatus.internalServerError, 'Could not find a suitable class for deserialization',); + } +{{/native_serialization}} +} +{{#native_serialization}} + +/// Primarily intended for use in an isolate. +class DeserializationMessage { + const DeserializationMessage({ + required this.json, + required this.targetType, + this.growable = false, + }); + + /// The JSON value to deserialize. + final String json; + + /// Target type to deserialize to. + final String targetType; + + /// Whether to make deserialized lists or maps growable. + final bool growable; +} + +/// Primarily intended for use in an isolate. +Future decodeAsync(DeserializationMessage message) async { + // Remove all spaces. Necessary for regular expressions as well. + final targetType = message.targetType.replaceAll(' ', ''); + + // If the expected target type is String, nothing to do... + return targetType == 'String' + ? message.json + : json.decode(message.json); +} + +/// Primarily intended for use in an isolate. +Future deserializeAsync(DeserializationMessage message) async { + // Remove all spaces. Necessary for regular expressions as well. + final targetType = message.targetType.replaceAll(' ', ''); + + // If the expected target type is String, nothing to do... + return targetType == 'String' + ? message.json + : ApiClient.fromJson( + json.decode(message.json), + targetType, + growable: message.growable, + ); +} +{{/native_serialization}} + +/// Primarily intended for use in an isolate. +Future serializeAsync(Object? value) async => value == null ? '' : json.encode(value); diff --git a/open-api/templates/mobile/api_client.mustache.patch b/open-api/templates/mobile/api_client.mustache.patch new file mode 100644 index 0000000000..3805cd8f79 --- /dev/null +++ b/open-api/templates/mobile/api_client.mustache.patch @@ -0,0 +1,10 @@ +--- api_client.mustache 2024-08-13 14:29:04.056364916 -0500 ++++ api_client_new.mustache 2024-08-13 14:29:36.224410735 -0500 +@@ -159,6 +159,7 @@ + {{#native_serialization}} + /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. + static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { ++ upgradeDto(value, targetType); + try { + switch (targetType) { + case 'String': diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 8c50d00581..3305e1cce1 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -87,7 +87,7 @@ class AvatarResponse { } class RatingResponse { - enabled!: boolean; + enabled: boolean = false; } class MemoryResponse {