harden media
This commit is contained in:
148
core/ports/media/MediaResolverPort.ts
Normal file
148
core/ports/media/MediaResolverPort.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Port: MediaResolverPort
|
||||
*
|
||||
* Interface for resolving MediaReference objects to actual URLs.
|
||||
* Part of the clean architecture ports layer.
|
||||
*
|
||||
* Implementations:
|
||||
* - InMemoryMediaResolverAdapter (for tests/stubs)
|
||||
* - HttpMediaResolverAdapter (for production HTTP resolution)
|
||||
* - FileSystemMediaResolverAdapter (for local file resolution)
|
||||
*/
|
||||
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
/**
|
||||
* MediaResolverPort interface
|
||||
*
|
||||
* Resolves a MediaReference to a concrete path string or null if no media exists.
|
||||
* Returns path-only URLs (e.g., /media/teams/123/logo) without baseUrl.
|
||||
*
|
||||
* @param ref - The media reference to resolve
|
||||
* @returns Promise resolving to a path string or null
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resolver: MediaResolverPort = new MediaResolverAdapter();
|
||||
* const ref = MediaReference.createSystemDefault('avatar');
|
||||
* const path = await resolver.resolve(ref);
|
||||
* // Returns: '/media/default/male-default-avatar.png'
|
||||
* ```
|
||||
*/
|
||||
export interface MediaResolverPort {
|
||||
/**
|
||||
* Resolve a media reference to a path-only URL
|
||||
*
|
||||
* @param ref - The media reference to resolve
|
||||
* @returns Promise resolving to path string or null (for 'none' type or resolution failures)
|
||||
*
|
||||
* @throws Should not throw for valid inputs - returns null instead
|
||||
* @throws May throw for invalid inputs (null ref)
|
||||
*/
|
||||
resolve(ref: MediaReference): Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object implements MediaResolverPort
|
||||
*/
|
||||
export function isMediaResolverPort(obj: unknown): obj is MediaResolverPort {
|
||||
const typedObj = obj as MediaResolverPort;
|
||||
return (
|
||||
obj !== null &&
|
||||
typeof obj === 'object' &&
|
||||
typeof typedObj.resolve === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default resolution strategies for different reference types
|
||||
* Returns path-only URLs
|
||||
*/
|
||||
export const ResolutionStrategies = {
|
||||
/**
|
||||
* Resolve system-default references
|
||||
* Format: /media/default/{variant}
|
||||
*/
|
||||
systemDefault: (ref: MediaReference): string | null => {
|
||||
if (ref.type !== 'system-default') return null;
|
||||
|
||||
let filename: string;
|
||||
if (ref.variant === 'avatar' && ref.avatarVariant) {
|
||||
filename = `${ref.avatarVariant}-default-avatar.png`;
|
||||
} else if (ref.variant === 'avatar') {
|
||||
filename = `neutral-default-avatar.png`;
|
||||
} else {
|
||||
filename = `${ref.variant}.png`;
|
||||
}
|
||||
|
||||
return `/media/default/${filename}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve generated references
|
||||
* Format: /media/teams/{id}/logo, /media/leagues/{id}/logo, /media/avatar/{id}
|
||||
*/
|
||||
generated: (ref: MediaReference): string | null => {
|
||||
if (ref.type !== 'generated') return null;
|
||||
|
||||
if (!ref.generationRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstHyphenIndex = ref.generationRequestId.indexOf('-');
|
||||
if (firstHyphenIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = ref.generationRequestId.substring(0, firstHyphenIndex);
|
||||
const id = ref.generationRequestId.substring(firstHyphenIndex + 1);
|
||||
|
||||
if (type === 'team') {
|
||||
return `/media/teams/${id}/logo`;
|
||||
} else if (type === 'league') {
|
||||
return `/media/leagues/${id}/logo`;
|
||||
} else if (type === 'driver') {
|
||||
return `/media/avatar/${id}`;
|
||||
}
|
||||
|
||||
return `/media/generated/${type}/${id}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve uploaded references
|
||||
* Format: /media/uploaded/{mediaId}
|
||||
*/
|
||||
uploaded: (ref: MediaReference): string | null => {
|
||||
if (ref.type !== 'uploaded') return null;
|
||||
if (!ref.mediaId) return null;
|
||||
return `/media/uploaded/${ref.mediaId}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve none references
|
||||
* Returns: null
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
none: (_ref: MediaReference): string | null => {
|
||||
return null;
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Helper function to resolve using default strategies
|
||||
*/
|
||||
export function resolveWithDefaults(ref: MediaReference): string | null {
|
||||
switch (ref.type) {
|
||||
case 'system-default':
|
||||
return ResolutionStrategies.systemDefault(ref);
|
||||
case 'generated':
|
||||
return ResolutionStrategies.generated(ref);
|
||||
case 'uploaded':
|
||||
return ResolutionStrategies.uploaded(ref);
|
||||
case 'none':
|
||||
return ResolutionStrategies.none(ref);
|
||||
default:
|
||||
// Exhaustive check - TypeScript will error if we miss a case
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user