Compare commits

...

2 Commits

Author SHA1 Message Date
shenlong-tanwen d732a690a1 ci: verify mobile backward compatibility 2026-06-03 09:30:38 +05:30
shenlong 814c2e32e4 chore: patch minFaces and realtimeTranscoding (#28784)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-03 09:15:31 +05:30
3 changed files with 189 additions and 61 deletions
+33
View File
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- 'open-api/**'
- 'mobile/lib/utils/openapi_patching.dart'
- '.github/workflows/check-openapi.yml'
concurrency:
@@ -29,3 +30,35 @@ jobs:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR
check-mobile-patches:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ github.token }}
- name: Get packages
working-directory: ./mobile
run: flutter pub get
- name: Fetch base spec from main
run: |
curl -fsSL \
"https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json" \
-o /tmp/base-spec.json
- name: Check newly-required fields have a backward-compat patch
working-directory: ./mobile
env:
OPENAPI_BASE_SPEC: /tmp/base-spec.json
OPENAPI_REVISION_SPEC: ../open-api/immich-openapi-specs.json
run: flutter test test/openapi_patches_coverage.dart
+52 -61
View File
@@ -1,67 +1,58 @@
import 'package:flutter/foundation.dart';
import 'package:openapi/api.dart';
dynamic upgradeDto(dynamic value, String targetType) {
switch (targetType) {
case 'UserPreferencesResponseDto':
if (value is Map) {
addDefault(value, 'download.includeEmbeddedVideos', false);
addDefault(value, 'folders', FoldersResponse(enabled: false, sidebarWeb: false).toJson());
addDefault(value, 'memories', MemoriesResponse(enabled: true, duration: 5).toJson());
addDefault(value, 'ratings', RatingsResponse(enabled: false).toJson());
addDefault(value, 'people', PeopleResponse(enabled: true, sidebarWeb: false).toJson());
addDefault(value, 'tags', TagsResponse(enabled: false, sidebarWeb: false).toJson());
addDefault(value, 'sharedLinks', SharedLinksResponse(enabled: true, sidebarWeb: false).toJson());
addDefault(value, 'cast', CastResponse(gCastEnabled: false).toJson());
addDefault(value, 'albums', {'defaultAssetOrder': 'desc'});
}
break;
case 'ServerConfigDto':
if (value is Map) {
addDefault(value, 'mapLightStyleUrl', 'https://tiles.immich.cloud/v1/style/light.json');
addDefault(value, 'mapDarkStyleUrl', 'https://tiles.immich.cloud/v1/style/dark.json');
}
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
case 'AssetResponseDto':
if (value is Map) {
addDefault(value, 'visibility', 'timeline');
addDefault(value, 'createdAt', DateTime.now().toIso8601String());
addDefault(value, 'isEdited', false);
}
break;
case 'UserAdminResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
case 'LoginResponseDto':
if (value is Map) {
addDefault(value, 'isOnboarded', false);
}
break;
case 'SyncUserV1':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
addDefault(value, 'hasProfileImage', false);
}
case 'SyncAssetV1':
if (value is Map) {
addDefault(value, 'isEdited', false);
}
case 'ServerFeaturesDto':
if (value is Map) {
addDefault(value, 'ocr', false);
}
break;
case 'MemoriesResponse':
if (value is Map) {
addDefault(value, 'duration', 5);
}
break;
abstract interface class _Dynamic {
Object? resolve();
}
class _CurrentTimestamp implements _Dynamic {
const _CurrentTimestamp();
@override
Object? resolve() => DateTime.now().toIso8601String();
}
const _now = _CurrentTimestamp();
@visibleForTesting
final Map<String, Map<String, Object?>> openApiPatches = {
'UserPreferencesResponseDto': {
'download.includeEmbeddedVideos': false,
'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(),
'memories': MemoriesResponse(enabled: true, duration: 5).toJson(),
'ratings': RatingsResponse(enabled: false).toJson(),
'people': PeopleResponse(enabled: true, sidebarWeb: false).toJson(),
'tags': TagsResponse(enabled: false, sidebarWeb: false).toJson(),
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
'cast': CastResponse(gCastEnabled: false).toJson(),
'albums': {'defaultAssetOrder': 'desc'},
},
'ServerConfigDto': {
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
'mapDarkStyleUrl': 'https://tiles.immich.cloud/v1/style/dark.json',
'minFaces': 3,
},
'UserResponseDto': {'profileChangedAt': _now},
'AssetResponseDto': {'visibility': 'timeline', 'createdAt': _now, 'isEdited': false},
'UserAdminResponseDto': {'profileChangedAt': _now},
'LoginResponseDto': {'isOnboarded': false},
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
'SyncAssetV1': {'isEdited': false},
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
'MemoriesResponse': {'duration': 5},
};
void upgradeDto(dynamic value, String targetType) {
if (value is! Map) {
return;
}
final fields = openApiPatches[targetType];
if (fields == null) {
return;
}
fields.forEach((key, defaultValue) {
addDefault(value, key, defaultValue is _Dynamic ? defaultValue.resolve() : defaultValue);
});
}
addDefault(dynamic value, String keys, dynamic defaultValue) {
+104
View File
@@ -0,0 +1,104 @@
// Intentionally NOT named `*_test.dart`: that suffix makes `flutter test`
// auto-discover it, which would run it on every mobile PR. This check is only
// relevant when the OpenAPI spec changes, so the `Check OpenAPI` workflow runs
// it by explicit path with the spec locations in the environment.
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/openapi_patching.dart';
void main() {
test('every newly-required response field has a backward-compat patch', () {
final basePath = Platform.environment['OPENAPI_BASE_SPEC'];
final revisionPath = Platform.environment['OPENAPI_REVISION_SPEC'];
if (basePath == null || revisionPath == null) {
markTestSkipped('set OPENAPI_BASE_SPEC and OPENAPI_REVISION_SPEC to run');
return;
}
final baseRequired = _requiredBySchema(_loadSpec(basePath));
final revisionSpec = _loadSpec(revisionPath);
final revisionRequired = _requiredBySchema(revisionSpec);
final deserialized = _deserializedSchemas(revisionSpec);
final patched = openApiPatches.map((type, fields) => MapEntry(type, fields.keys.toSet()));
final missing = <String>[];
for (final entry in revisionRequired.entries) {
if (!deserialized.contains(entry.key)) {
continue;
}
final have = patched[entry.key] ?? const <String>{};
final newlyRequired = entry.value.difference(baseRequired[entry.key] ?? const <String>{});
for (final field in newlyRequired) {
if (!have.contains(field)) {
missing.add('${entry.key}.$field');
}
}
}
missing.sort();
expect(
missing,
isEmpty,
reason:
'These newly-required fields will be omitted by older servers and trip the generated '
'null-assertion.\nAdd a default to openApiPatches in lib/utils/openapi_patching.dart: $missing',
);
});
}
Map<String, dynamic> _loadSpec(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
Map<String, dynamic> _schemas(Map<String, dynamic> spec) =>
((spec['components'] as Map?)?['schemas'] as Map?)?.cast<String, dynamic>() ?? const {};
Map<String, Set<String>> _requiredBySchema(Map<String, dynamic> spec) {
final result = <String, Set<String>>{};
_schemas(spec).forEach((name, schema) {
final required = (schema as Map)['required'] as List? ?? const [];
result[name] = required.cast<String>().toSet();
});
return result;
}
Iterable<String> _refsIn(Object? node) sync* {
if (node is Map) {
if (node[r'$ref'] case final String ref) {
yield ref.split('/').last;
}
for (final value in node.values) {
yield* _refsIn(value);
}
} else if (node is List) {
for (final value in node) {
yield* _refsIn(value);
}
}
}
Set<String> _deserializedSchemas(Map<String, dynamic> spec) {
final schemas = _schemas(spec);
final reachable = <String>{};
final queue = <String>[];
for (final path in (spec['paths'] as Map?)?.values ?? const []) {
if (path is! Map) {
continue;
}
for (final operation in path.values) {
if (operation is Map) {
queue.addAll(_refsIn(operation['responses']));
}
}
}
while (queue.isNotEmpty) {
final name = queue.removeLast();
if (!schemas.containsKey(name) || !reachable.add(name)) {
continue;
}
queue.addAll(_refsIn(schemas[name]));
}
return reachable;
}