feat: endpoint versioning (#23858)

This commit is contained in:
Jason Rasmussen
2025-11-13 08:18:43 -05:00
committed by GitHub
parent e0535e20e6
commit 4a6c50cd81
53 changed files with 4247 additions and 705 deletions
+112 -30
View File
@@ -1,8 +1,7 @@
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiExtension, ApiOperation, ApiOperationOptions, ApiProperty, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository';
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
@@ -153,39 +152,122 @@ export type JobConfig = {
};
export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JobConfig, config);
type LifecycleRelease = 'NEXT_RELEASE' | string;
type LifecycleMetadata = {
addedAt?: LifecycleRelease;
deprecatedAt?: LifecycleRelease;
};
type EndpointOptions = ApiOperationOptions & { history?: HistoryBuilder };
export const Endpoint = ({ history, ...options }: EndpointOptions) => {
const decorators: MethodDecorator[] = [];
const extensions = history?.getExtensions() ?? {};
export const EndpointLifecycle = ({
addedAt,
deprecatedAt,
description,
...options
}: LifecycleMetadata & ApiOperationOptions) => {
const decorators: MethodDecorator[] = [ApiExtension(LIFECYCLE_EXTENSION, { addedAt, deprecatedAt })];
if (deprecatedAt) {
decorators.push(
ApiTags(ApiTag.Deprecated),
ApiOperation({
deprecated: true,
description: DEPRECATED_IN_PREFIX + deprecatedAt + (description ? `. ${description}` : ''),
...options,
}),
);
if (!extensions[ApiCustomExtension.History]) {
console.log(`Missing history for endpoint: ${options.summary}`);
}
if (history?.isDeprecated()) {
options.deprecated = true;
decorators.push(ApiTags(ApiTag.Deprecated));
}
decorators.push(ApiOperation({ ...options, ...extensions }));
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 }));
type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
export const Property = ({ history, ...options }: PropertyOptions) => {
const extensions = history?.getExtensions() ?? {};
if (history?.isDeprecated()) {
options.deprecated = true;
}
return applyDecorators(...decorators);
return ApiProperty({ ...options, ...extensions });
};
type HistoryEntry = {
version: string;
state: ApiState | 'Added' | 'Updated';
description?: string;
replacementId?: string;
};
type DeprecatedOptions = {
/** replacement operationId */
replacementId?: string;
};
type CustomExtensions = {
[ApiCustomExtension.State]?: ApiState;
[ApiCustomExtension.History]?: HistoryEntry[];
};
enum ApiState {
'Stable' = 'Stable',
'Alpha' = 'Alpha',
'Beta' = 'Beta',
'Internal' = 'Internal',
'Deprecated' = 'Deprecated',
}
export class HistoryBuilder {
private hasDeprecated = false;
private items: HistoryEntry[] = [];
added(version: string, description?: string) {
return this.push({ version, state: 'Added', description });
}
updated(version: string, description: string) {
return this.push({ version, state: 'Updated', description });
}
alpha(version: string) {
return this.push({ version, state: ApiState.Alpha });
}
beta(version: string) {
return this.push({ version, state: ApiState.Beta });
}
internal(version: string) {
return this.push({ version, state: ApiState.Internal });
}
stable(version: string) {
return this.push({ version, state: ApiState.Stable });
}
deprecated(version: string, options?: DeprecatedOptions) {
const { replacementId } = options || {};
this.hasDeprecated = true;
return this.push({ version, state: ApiState.Deprecated, replacementId });
}
isDeprecated(): boolean {
return this.hasDeprecated;
}
getExtensions() {
const extensions: CustomExtensions = {};
if (this.items.length > 0) {
extensions[ApiCustomExtension.History] = this.items;
}
for (const item of this.items.toReversed()) {
if (item.state === 'Added' || item.state === 'Updated') {
continue;
}
extensions[ApiCustomExtension.State] = item.state;
break;
}
return extensions;
}
private push(item: HistoryEntry) {
if (!item.version.startsWith('v')) {
throw new Error(`Version string must start with 'v': received '${JSON.stringify(item)}'`);
}
this.items.push(item);
return this;
}
}