From 59caf1fce4c3fbc07933933a61d2ce0b92d6b88b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 29 Apr 2024 09:48:28 -0400 Subject: [PATCH] chore: lifecycle metadata (#9103) feat(server): track endpoint lifecycle --- mobile/openapi/doc/AddUsersDto.md | 2 +- mobile/openapi/doc/AlbumResponseDto.md | 2 +- mobile/openapi/doc/MemoryLaneResponseDto.md | 2 +- mobile/openapi/doc/MetadataSearchDto.md | 4 +- mobile/openapi/lib/model/add_users_dto.dart | 2 +- .../openapi/lib/model/album_response_dto.dart | 2 +- .../lib/model/memory_lane_response_dto.dart | 1 + .../lib/model/metadata_search_dto.dart | 2 + mobile/openapi/test/add_users_dto_test.dart | 2 +- .../openapi/test/album_response_dto_test.dart | 2 +- .../test/memory_lane_response_dto_test.dart | 1 + .../test/metadata_search_dto_test.dart | 2 + open-api/immich-openapi-specs.json | 7 +- open-api/typescript-sdk/src/fetch-client.ts | 7 +- server/package.json | 1 + server/src/constants.ts | 5 + server/src/decorators.ts | 32 ++++++- server/src/dtos/album.dto.ts | 5 +- server/src/dtos/asset-response.dto.ts | 3 +- server/src/dtos/search.dto.ts | 5 +- server/src/utils/lifecycle.ts | 93 +++++++++++++++++++ server/src/utils/version.ts | 8 ++ 22 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 server/src/utils/lifecycle.ts diff --git a/mobile/openapi/doc/AddUsersDto.md b/mobile/openapi/doc/AddUsersDto.md index a8f7723441..5547c5a70b 100644 --- a/mobile/openapi/doc/AddUsersDto.md +++ b/mobile/openapi/doc/AddUsersDto.md @@ -9,7 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **albumUsers** | [**List**](AlbumUserAddDto.md) | | [default to const []] -**sharedUserIds** | **List** | Deprecated in favor of albumUsers | [optional] [default to const []] +**sharedUserIds** | **List** | This property was deprecated in v1.102.0 | [optional] [default to const []] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index b7965b4200..0152396c25 100644 --- a/mobile/openapi/doc/AlbumResponseDto.md +++ b/mobile/openapi/doc/AlbumResponseDto.md @@ -24,7 +24,7 @@ Name | Type | Description | Notes **owner** | [**UserResponseDto**](UserResponseDto.md) | | **ownerId** | **String** | | **shared** | **bool** | | -**sharedUsers** | [**List**](UserResponseDto.md) | Deprecated in favor of albumUsers | [default to const []] +**sharedUsers** | [**List**](UserResponseDto.md) | This property was deprecated in v1.102.0 | [default to const []] **startDate** | [**DateTime**](DateTime.md) | | [optional] **updatedAt** | [**DateTime**](DateTime.md) | | diff --git a/mobile/openapi/doc/MemoryLaneResponseDto.md b/mobile/openapi/doc/MemoryLaneResponseDto.md index 54d1a4769a..7d08b512cc 100644 --- a/mobile/openapi/doc/MemoryLaneResponseDto.md +++ b/mobile/openapi/doc/MemoryLaneResponseDto.md @@ -9,7 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **assets** | [**List**](AssetResponseDto.md) | | [default to const []] -**title** | **String** | | +**title** | **String** | This property was deprecated in v1.100.0 | **yearsAgo** | **int** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/MetadataSearchDto.md b/mobile/openapi/doc/MetadataSearchDto.md index 5dc50c00fa..d9448bd7f7 100644 --- a/mobile/openapi/doc/MetadataSearchDto.md +++ b/mobile/openapi/doc/MetadataSearchDto.md @@ -36,7 +36,7 @@ Name | Type | Description | Notes **page** | **num** | | [optional] **personIds** | **List** | | [optional] [default to const []] **previewPath** | **String** | | [optional] -**resizePath** | **String** | | [optional] +**resizePath** | **String** | This property was deprecated in v1.100.0 | [optional] **size** | **num** | | [optional] **state** | **String** | | [optional] **takenAfter** | [**DateTime**](DateTime.md) | | [optional] @@ -47,7 +47,7 @@ Name | Type | Description | Notes **type** | [**AssetTypeEnum**](AssetTypeEnum.md) | | [optional] **updatedAfter** | [**DateTime**](DateTime.md) | | [optional] **updatedBefore** | [**DateTime**](DateTime.md) | | [optional] -**webpPath** | **String** | | [optional] +**webpPath** | **String** | This property was deprecated in v1.100.0 | [optional] **withArchived** | **bool** | | [optional] [default to false] **withDeleted** | **bool** | | [optional] **withExif** | **bool** | | [optional] diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 806bc60f42..ad58577b53 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -19,7 +19,7 @@ class AddUsersDto { List albumUsers; - /// Deprecated in favor of albumUsers + /// This property was deprecated in v1.102.0 List sharedUserIds; @override diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index cae01150f6..79c75bc58c 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -84,7 +84,7 @@ class AlbumResponseDto { bool shared; - /// Deprecated in favor of albumUsers + /// This property was deprecated in v1.102.0 List sharedUsers; /// diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index a0df079388..2f1f659529 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -20,6 +20,7 @@ class MemoryLaneResponseDto { List assets; + /// This property was deprecated in v1.100.0 String title; int yearsAgo; diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index ee5e7aa4f6..61bf44ae12 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -279,6 +279,7 @@ class MetadataSearchDto { /// String? previewPath; + /// This property was deprecated in v1.100.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -369,6 +370,7 @@ class MetadataSearchDto { /// DateTime? updatedBefore; + /// This property was deprecated in v1.100.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated diff --git a/mobile/openapi/test/add_users_dto_test.dart b/mobile/openapi/test/add_users_dto_test.dart index 0c3bbb759d..b0d66c56d8 100644 --- a/mobile/openapi/test/add_users_dto_test.dart +++ b/mobile/openapi/test/add_users_dto_test.dart @@ -21,7 +21,7 @@ void main() { // TODO }); - // Deprecated in favor of albumUsers + // This property was deprecated in v1.102.0 // List sharedUserIds (default value: const []) test('to test the property `sharedUserIds`', () async { // TODO diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index 12218d3829..b9702165b5 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/mobile/openapi/test/album_response_dto_test.dart @@ -96,7 +96,7 @@ void main() { // TODO }); - // Deprecated in favor of albumUsers + // This property was deprecated in v1.102.0 // List sharedUsers (default value: const []) test('to test the property `sharedUsers`', () async { // TODO diff --git a/mobile/openapi/test/memory_lane_response_dto_test.dart b/mobile/openapi/test/memory_lane_response_dto_test.dart index 4ed84f5ecd..a48d757e2c 100644 --- a/mobile/openapi/test/memory_lane_response_dto_test.dart +++ b/mobile/openapi/test/memory_lane_response_dto_test.dart @@ -21,6 +21,7 @@ void main() { // TODO }); + // This property was deprecated in v1.100.0 // String title test('to test the property `title`', () async { // TODO diff --git a/mobile/openapi/test/metadata_search_dto_test.dart b/mobile/openapi/test/metadata_search_dto_test.dart index 62979da9c0..8037f08c67 100644 --- a/mobile/openapi/test/metadata_search_dto_test.dart +++ b/mobile/openapi/test/metadata_search_dto_test.dart @@ -156,6 +156,7 @@ void main() { // TODO }); + // This property was deprecated in v1.100.0 // String resizePath test('to test the property `resizePath`', () async { // TODO @@ -211,6 +212,7 @@ void main() { // TODO }); + // This property was deprecated in v1.100.0 // String webpPath test('to test the property `webpPath`', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ec859d56e2..177b819aee 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6616,7 +6616,7 @@ }, "sharedUserIds": { "deprecated": true, - "description": "Deprecated in favor of albumUsers", + "description": "This property was deprecated in v1.102.0", "items": { "format": "uuid", "type": "string" @@ -6721,7 +6721,7 @@ }, "sharedUsers": { "deprecated": true, - "description": "Deprecated in favor of albumUsers", + "description": "This property was deprecated in v1.102.0", "items": { "$ref": "#/components/schemas/UserResponseDto" }, @@ -8433,6 +8433,7 @@ }, "title": { "deprecated": true, + "description": "This property was deprecated in v1.100.0", "type": "string" }, "yearsAgo": { @@ -8640,6 +8641,7 @@ }, "resizePath": { "deprecated": true, + "description": "This property was deprecated in v1.100.0", "type": "string" }, "size": { @@ -8682,6 +8684,7 @@ }, "webpPath": { "deprecated": true, + "description": "This property was deprecated in v1.100.0", "type": "string" }, "withArchived": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 92fc5cd59c..3bcb444ae4 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -162,7 +162,7 @@ export type AlbumResponseDto = { owner: UserResponseDto; ownerId: string; shared: boolean; - /** Deprecated in favor of albumUsers */ + /** This property was deprecated in v1.102.0 */ sharedUsers: UserResponseDto[]; startDate?: string; updatedAt: string; @@ -202,7 +202,7 @@ export type AlbumUserAddDto = { }; export type AddUsersDto = { albumUsers: AlbumUserAddDto[]; - /** Deprecated in favor of albumUsers */ + /** This property was deprecated in v1.102.0 */ sharedUserIds?: string[]; }; export type ApiKeyResponseDto = { @@ -273,6 +273,7 @@ export type MapMarkerResponseDto = { }; export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; + /** This property was deprecated in v1.100.0 */ title: string; yearsAgo: number; }; @@ -637,6 +638,7 @@ export type MetadataSearchDto = { page?: number; personIds?: string[]; previewPath?: string; + /** This property was deprecated in v1.100.0 */ resizePath?: string; size?: number; state?: string; @@ -648,6 +650,7 @@ export type MetadataSearchDto = { "type"?: AssetTypeEnum; updatedAfter?: string; updatedBefore?: string; + /** This property was deprecated in v1.100.0 */ webpPath?: string; withArchived?: boolean; withDeleted?: boolean; diff --git a/server/package.json b/server/package.json index 274eddd304..0f6c6f45dd 100644 --- a/server/package.json +++ b/server/package.json @@ -22,6 +22,7 @@ "test:watch": "vitest --watch", "test:cov": "vitest --coverage", "typeorm": "typeorm", + "lifecycle": "node ./dist/utils/lifecycle.js", "typeorm:migrations:create": "typeorm migration:create", "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/database.config.js", "typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js", diff --git a/server/src/constants.ts b/server/src/constants.ts index d9d4232396..b6d6de815e 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { Version } from 'src/utils/version'; +export const NEXT_RELEASE = 'NEXT_RELEASE'; +export const LIFECYCLE_EXTENSION = 'x-immich-lifecycle'; +export const DEPRECATED_IN_PREFIX = 'This property was deprecated in '; +export const ADDED_IN_PREFIX = 'This property was added in '; + export const SALT_ROUNDS = 10; const { version } = JSON.parse(readFileSync('./package.json', 'utf8')); diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 39da2aa2a5..9f80ab68a5 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,7 +1,9 @@ -import { SetMetadata } from '@nestjs/common'; +import { SetMetadata, applyDecorators } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; +import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; +import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { ServerAsyncEvent, ServerEvent } from 'src/interfaces/event.interface'; import { setUnion } from 'src/utils/set'; @@ -128,3 +130,31 @@ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GEN export const OnServerEvent = (event: ServerEvent | ServerAsyncEvent, options?: OnEventOptions) => OnEvent(event, { suppressErrors: false, ...options }); + +type LifecycleRelease = 'NEXT_RELEASE' | string; +type LifecycleMetadata = { + addedAt?: LifecycleRelease; + deprecatedAt?: LifecycleRelease; +}; + +export const EndpointLifecycle = ({ addedAt, deprecatedAt }: LifecycleMetadata) => { + const decorators: MethodDecorator[] = [ApiExtension(LIFECYCLE_EXTENSION, { addedAt, deprecatedAt })]; + if (deprecatedAt) { + decorators.push( + ApiTags('Deprecated'), + ApiOperation({ deprecated: true, description: DEPRECATED_IN_PREFIX + deprecatedAt }), + ); + } + + return applyDecorators(...decorators); +}; + +export const PropertyLifecycle = ({ addedAt, deprecatedAt }: LifecycleMetadata) => { + const decorators: PropertyDecorator[] = []; + decorators.push(ApiProperty({ description: ADDED_IN_PREFIX + addedAt })); + if (deprecatedAt) { + decorators.push(ApiProperty({ deprecated: true, description: DEPRECATED_IN_PREFIX + deprecatedAt })); + } + + return applyDecorators(...decorators); +}; diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 0f96e52b12..f6a954dcdd 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayNotEmpty, IsEnum, IsString } from 'class-validator'; import _ from 'lodash'; +import { PropertyLifecycle } from 'src/decorators'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; @@ -25,7 +26,7 @@ export class AlbumUserAddDto { export class AddUsersDto { @ValidateUUID({ each: true, optional: true }) @ArrayNotEmpty() - @ApiProperty({ deprecated: true, description: 'Deprecated in favor of albumUsers' }) + @PropertyLifecycle({ deprecatedAt: 'v1.102.0' }) sharedUserIds?: string[]; @ArrayNotEmpty() @@ -119,7 +120,7 @@ export class AlbumResponseDto { updatedAt!: Date; albumThumbnailAssetId!: string | null; shared!: boolean; - @ApiProperty({ deprecated: true, description: 'Deprecated in favor of albumUsers' }) + @PropertyLifecycle({ deprecatedAt: 'v1.102.0' }) sharedUsers!: UserResponseDto[]; albumUsers!: AlbumUserResponseDto[]; hasSharedLink!: boolean; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index bdda36d15e..d094511bfb 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; import { PersonWithFacesResponseDto, mapFacesWithoutPerson, mapPerson } from 'src/dtos/person.dto'; @@ -131,7 +132,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As } export class MemoryLaneResponseDto { - @ApiProperty({ deprecated: true }) + @PropertyLifecycle({ deprecatedAt: 'v1.100.0' }) title!: string; @ApiProperty({ type: 'integer' }) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 3304aae8cf..0eb2aae749 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; +import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetOrder } from 'src/entities/album.entity'; @@ -163,13 +164,13 @@ export class MetadataSearchDto extends BaseSearchDto { @IsString() @IsNotEmpty() @Optional() - @ApiProperty({ deprecated: true }) + @PropertyLifecycle({ deprecatedAt: 'v1.100.0' }) resizePath?: string; @IsString() @IsNotEmpty() @Optional() - @ApiProperty({ deprecated: true }) + @PropertyLifecycle({ deprecatedAt: 'v1.100.0' }) webpPath?: string; @IsString() diff --git a/server/src/utils/lifecycle.ts b/server/src/utils/lifecycle.ts new file mode 100644 index 0000000000..9639ab609e --- /dev/null +++ b/server/src/utils/lifecycle.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { OpenAPIObject } from '@nestjs/swagger'; +import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION, NEXT_RELEASE } from 'src/constants'; +import { Version } from 'src/utils/version'; + +const outputPath = resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); +const spec = JSON.parse(readFileSync(outputPath).toString()) as OpenAPIObject; + +type Items = { + oldEndpoints: Endpoint[]; + newEndpoints: Endpoint[]; + oldProperties: Property[]; + newProperties: Property[]; +}; +type Endpoint = { url: string; method: string; endpoint: any }; +type Property = { schema: string; property: string }; + +const metadata: Record = {}; +const trackVersion = (version: string) => { + if (!metadata[version]) { + metadata[version] = { + oldEndpoints: [], + newEndpoints: [], + oldProperties: [], + newProperties: [], + }; + } + return metadata[version]; +}; + +for (const [url, methods] of Object.entries(spec.paths)) { + for (const [method, endpoint] of Object.entries(methods) as Array<[string, any]>) { + const deprecatedAt = endpoint[LIFECYCLE_EXTENSION]?.deprecatedAt; + if (deprecatedAt) { + trackVersion(deprecatedAt).oldEndpoints.push({ url, method, endpoint }); + } + + const addedAt = endpoint[LIFECYCLE_EXTENSION]?.addedAt; + if (addedAt) { + trackVersion(addedAt).newEndpoints.push({ url, method, endpoint }); + } + } +} + +for (const [schemaName, schema] of Object.entries(spec.components?.schemas || {})) { + for (const [propertyName, property] of Object.entries((schema as SchemaObject).properties || {})) { + const propertySchema = property as SchemaObject; + if (propertySchema.description?.startsWith(DEPRECATED_IN_PREFIX)) { + const deprecatedAt = propertySchema.description.replace(DEPRECATED_IN_PREFIX, '').trim(); + trackVersion(deprecatedAt).oldProperties.push({ schema: schemaName, property: propertyName }); + } + + if (propertySchema.description?.startsWith(ADDED_IN_PREFIX)) { + const addedAt = propertySchema.description.replace(ADDED_IN_PREFIX, '').trim(); + trackVersion(addedAt).newProperties.push({ schema: schemaName, property: propertyName }); + } + } +} + +const sortedVersions = Object.keys(metadata).sort((a, b) => { + if (a === NEXT_RELEASE) { + return -1; + } + + if (b === NEXT_RELEASE) { + return 1; + } + + const versionA = Version.fromString(a); + const versionB = Version.fromString(b); + return versionB.compareTo(versionA); +}); + +for (const version of sortedVersions) { + const { oldEndpoints, newEndpoints, oldProperties, newProperties } = metadata[version]; + console.log(`\nChanges in ${version}`); + console.log('---------------------'); + for (const { url, method, endpoint } of oldEndpoints) { + console.log(`- Deprecated ${method.toUpperCase()} ${url} (${endpoint.operationId})`); + } + for (const { url, method, endpoint } of newEndpoints) { + console.log(`- Added ${method.toUpperCase()} ${url} (${endpoint.operationId})`); + } + for (const { schema, property } of oldProperties) { + console.log(`- Deprecated ${schema}.${property}`); + } + for (const { schema, property } of newProperties) { + console.log(`- Added ${schema}.${property}`); + } +} diff --git a/server/src/utils/version.ts b/server/src/utils/version.ts index 6eca12eb49..e53f64f9d9 100644 --- a/server/src/utils/version.ts +++ b/server/src/utils/version.ts @@ -61,4 +61,12 @@ export class Version implements IVersion { const [bool, type] = this.compare(version); return bool > 0 ? type : VersionType.EQUAL; } + + compareTo(other: Version) { + if (this.isEqual(other)) { + return 0; + } + + return this.isNewerThan(other) ? 1 : -1; + } }