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 | Bin 577 -> 584 bytes mobile/openapi/doc/AlbumResponseDto.md | Bin 1422 -> 1429 bytes mobile/openapi/doc/MemoryLaneResponseDto.md | Bin 535 -> 575 bytes mobile/openapi/doc/MetadataSearchDto.md | Bin 2574 -> 2654 bytes mobile/openapi/lib/model/add_users_dto.dart | Bin 3243 -> 3250 bytes .../openapi/lib/model/album_response_dto.dart | Bin 9951 -> 9958 bytes .../lib/model/memory_lane_response_dto.dart | Bin 3375 -> 3422 bytes .../lib/model/metadata_search_dto.dart | Bin 33485 -> 33579 bytes mobile/openapi/test/add_users_dto_test.dart | Bin 786 -> 793 bytes .../openapi/test/album_response_dto_test.dart | Bin 2656 -> 2663 bytes .../test/memory_lane_response_dto_test.dart | Bin 818 -> 866 bytes .../test/metadata_search_dto_test.dart | Bin 5189 -> 5285 bytes 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, 156 insertions(+), 10 deletions(-) create mode 100644 server/src/utils/lifecycle.ts diff --git a/mobile/openapi/doc/AddUsersDto.md b/mobile/openapi/doc/AddUsersDto.md index a8f7723441c20d3f196e26e6a655ee57840fe58c..5547c5a70bd6ea24213241e82286d87fa5441e9a 100644 GIT binary patch delta 52 zcmX@ea)M<;IHN{LMrN@>K~a7|YEen0LV04bLP}~uQEGBxNotBhW}ZTsp`M|Ek)FZi H3dWNF_BIkt delta 45 zcmX@Xa*$<1IHRIVYC%zIa$-qpib7_dLRw;3evv|cnnGeuQfY2zacWWV diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index b7965b42008e34bcff050187419df00c00617afd..0152396c252af9b473eaea445f7bb058ccb8dfe9 100644 GIT binary patch delta 53 zcmeC@pf&CM)A Fi~zoZ5taY| diff --git a/mobile/openapi/doc/MemoryLaneResponseDto.md b/mobile/openapi/doc/MemoryLaneResponseDto.md index 54d1a4769a4ad1c7c13840c3df60860776e0d6a9..7d08b512ccb0073b237458620e45a721531f2c30 100644 GIT binary patch delta 52 zcmbQvvY%x`AfrY|MrN@>K~a7|YEen0LV04bLP}~uQEGBxNotBhW}ZTsp`M|Efu6zS HK*nPL>P!*n delta 11 ScmdnbGM!~ZAmijp#$x~(5d<^< diff --git a/mobile/openapi/doc/MetadataSearchDto.md b/mobile/openapi/doc/MetadataSearchDto.md index 5dc50c00fad9d91c4df2f1fdc21a86198e27f1c7..d9448bd7f76fa0d270832c8d363bc498742318e7 100644 GIT binary patch delta 99 zcmeAZxhJxLolPSoBePhcpeVl}wWy?0p**ozAtkk-C^b2;BsE1LGf$z+P|wi7K+j+^ PJ6i@5@v8GV<}(5So3J4M delta 25 dcmca7(kHTkoo%u#qu^!-ws0me;{rz?BLHG}2SNY< diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 806bc60f42e95b8e6a54bd93a7f8ea78ee8d7f9f..ad58577b532843184ce8aa92a4f90ce578ff7bd0 100644 GIT binary patch delta 53 zcmZ22xk+-vGe(V&jLc$%f};F_)S{9~h4RE=g_P8SqSWNXlGGH1%shoMLp?(SBRzx7 I>`Y6z03mM@ZvX%Q delta 46 zcmdlaxmt3=Ge$+1)PkbaRq|)5b;?$zz&3sHtxB#C8 B5f=ae diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index cae01150f65a2a098c8b70946c03586cb93dea44..79c75bc58ce592019ee762b6f948fb4d626b3b54 100644 GIT binary patch delta 53 zcmccb`^K~a7|YEen0LV04bLP}~uQEGBxNotBhW}ZTsp`M|Ek)FZk ID$Z0{05ioC2mk;8 delta 46 zcmaFnd*63MB&VWFYC%zIa$-qpib7_dLRw;3evv|cnnGeuQfY2zacWWV=0?s`Spct2 B5wHLN diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index a0df079388335035505b2a33fc356dbef94810e2..2f1f659529c3018e7d7141399b2be7956133e058 100644 GIT binary patch delta 57 zcmZ24bx&#oH_8MrN@>K~a7|YEen0LV04bLP}~uQEGBxNotBhW}ZTsp`M|E Mfu6x;4kmSO04*C4`v3p{ delta 12 Tcmca7wO(oiH`8V-CKYY~9qR*~ diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index ee5e7aa4f6ae65b1fc0a444c75a359bc19d4756f..61bf44ae129a2d6df4ae94d3d5168bd6f6a07e01 100644 GIT binary patch delta 121 zcmX@x%Cx$TX+xckrb0+YX0bv+QGP*cQAwpjd1A3bN@_t-YI0&pYKlT;o;M1& diff --git a/mobile/openapi/test/add_users_dto_test.dart b/mobile/openapi/test/add_users_dto_test.dart index 0c3bbb759d81dfb84857859b2cdd8de005ddee0f..b0d66c56d8fb8cc2d197a657007cbb88dc5ec87a 100644 GIT binary patch delta 52 zcmbQlHj`~bIFm+5MrN@>K~a7|YEen0LV04bLP}~uQEGBxNotBhW}ZTsp`M|Ek)FZi H3Z_{AORIFq7FYC%zIa$-qpib7_dLRw;3evv|cnnGeuQfY2zacWWVPz?0W}2^Z~y=R delta 46 zcmaDZ@<3$6ISxga)PkbaRq|)5b;?$zz&EGf*83C|z B5*Ppg diff --git a/mobile/openapi/test/memory_lane_response_dto_test.dart b/mobile/openapi/test/memory_lane_response_dto_test.dart index 4ed84f5ecd0564702f4d0e4a1e37334d5ddf9486..a48d757e2cb36aa42fff1984f4ea4522c5c9b344 100644 GIT binary patch delta 52 zcmdnQ_K0mm9+O5$MrN@>K~a7|YEen0LV04bLP}~uQEGBxNotBhW}ZTsp`M|Efu6zS HG^R5E{Voz@ delta 11 ScmaFFwux;+9@FG`Oh*75%LKRp diff --git a/mobile/openapi/test/metadata_search_dto_test.dart b/mobile/openapi/test/metadata_search_dto_test.dart index 62979da9c06447a41386f65184e68ec26ced502d..8037f08c6759adfb12af4910d920799ce44dac63 100644 GIT binary patch delta 99 zcmX@Au~c(|5wAu_MrN@>K~a7|YEen0LV04bLP}~uQEGBxNotBhW}ZTsp`M|Efu6x; PE#9dt#H-#e*vA9_&rTun delta 25 dcmZ3gc~oPA5%1&;EbN;zdD~dP3^k#8CIER72xb5P 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; + } }