1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-23 02:06:15 +02:00

chore: lifecycle metadata (#9103)

feat(server): track endpoint lifecycle
This commit is contained in:
Jason Rasmussen 2024-04-29 09:48:28 -04:00 committed by GitHub
parent 6eb5d2e95e
commit 59caf1fce4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 156 additions and 10 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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": {

View File

@ -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;

View File

@ -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",

View File

@ -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'));

View File

@ -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);
};

View File

@ -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;

View File

@ -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' })

View File

@ -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()

View File

@ -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<string, Items> = {};
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}`);
}
}

View File

@ -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;
}
}