Shop It Docs
Developer ResourcesUploads & Storage

StorageUrlResolver

Injectable service for resolving storage keys to viewable URLs at the service layer — so feature services never deal with raw S3 keys.

StorageUrlResolver

Audience: backend Scope: apps/api/src/common/services/storage-url-resolver.service.ts


What it does

StorageUrlResolver is a shared injectable that converts a storage key (relativePath) into a ready-to-use URL. It abstracts away whether the file is stored locally or in S3/MinIO, and whether it needs a signed URL or is publicly accessible.

Feature services call resolveFields after fetching entities from the DB — the resolver mutates the objects in-place so controllers and serializers see thumbnailUrl, thumbnailExpiresAt etc. without any extra work.


How to inject it

StorageUrlResolver is exported from UploadModule. Any feature module that needs URL resolution should import UploadModule:

@Module({
  imports: [UploadModule],
  providers: [ProductService],
})
export class ProductModule {}
@Injectable()
export class ProductService {
  constructor(
    private readonly storageUrlResolver: StorageUrlResolver,
  ) {}
}

API

resolve(key, options?)

Resolve a single key to a viewable URL.

const result = await storageUrlResolver.resolve("public/avatar/abc.webp");
// { url: "http://minio:9000/apple/public/avatar/abc.webp", expiresAt: null }

const result = await storageUrlResolver.resolve("uploads/image/xyz.webp");
// { url: "http://minio:9000/...?X-Amz-...", expiresAt: 1712250000000 }

const result = await storageUrlResolver.resolve(null);
// null

const result = await storageUrlResolver.resolve("", { expiresIn: 7200 });
// null (empty string treated as null)

Resolution rules:

Signed URL generation is a local HMAC-SHA256 operation. The AWS SDK computes the signature on the server without making a network request to MinIO. It is safe to call in hot paths (list endpoints, serializers).


resolveMany(keys, options?)

Resolve an array of keys in parallel. Preserves index order. Nulls pass through.

const results = await storageUrlResolver.resolveMany([
  "public/avatar/a.webp",
  null,
  "uploads/image/b.webp",
]);
// [
//   { url: "http://minio/.../public/avatar/a.webp", expiresAt: null },
//   null,
//   { url: "http://minio/...?X-Amz-...", expiresAt: 1712250000000 },
// ]

resolveFields(items, fields)

Mutates each item in-place, adding {field}Url and {field}ExpiresAt for every field listed. The original key field is preserved so the DB key survives serialization for client-side cache keys.


// Before:
// { id: 1, thumbnail: "public/thumbnail/abc.webp", previewVideo: "uploads/video/xyz.mp4" }

// After:
// {
//   id: 1,
//   thumbnail: "public/thumbnail/abc.webp",        // original key — still here
//   thumbnailUrl: "http://minio/.../public/...",    // added
//   thumbnailExpiresAt: null,                       // added (public — never expires)
//   previewVideo: "uploads/video/xyz.mp4",          // original key — still here
//   previewVideoUrl: "http://minio/...?X-Amz-...",  // added
//   previewVideoExpiresAt: 1712250000000,            // added (private — expires)
// }

Accepts a single object or an array — both work:

// Single object
await storageUrlResolver.resolveFields(user, ['avatar']);

// Array
await storageUrlResolver.resolveFields(posts, ['thumbnail', 'cover']);

Usage patterns

Pattern 1: Inline before return

async findAll(): Promise<ProductDto[]> {
}

Pattern 2: Single entity

async findOne(id: string): Promise<UserProfileDto> {
  const user = await this.db.query.user.findFirst({ where: eq(user.id, id) });
  await this.storageUrlResolver.resolveFields(user, ['avatar', 'coverImage']);
  return user;
}

Pattern 3: Explicit resolve for upload response

Upload responses already include url and expiresAtmapUploadResult in UploadService calls resolve automatically. No extra work needed after upload.

// Response from POST /upload includes:
{
  "relativePath": "uploads/image/abc.webp",  // store this in DB
  "url": "http://minio/...?X-Amz-...",       // use this immediately
  "expiresAt": 1712250000000
}

Frontend caching guidance

The expiresAt field tells the frontend whether to cache the URL and when to refresh it.

interface CachedUrl {
  url: string;
  expiresAt: number | null; // null = never expires (public or local)
}

const cache = new Map<string, CachedUrl>();
const BUFFER_MS = 60_000; // refresh 1 min before expiry

function isFresh(cached: CachedUrl): boolean {
  if (cached.expiresAt === null) return true; // public — never stale
  return cached.expiresAt > Date.now() + BUFFER_MS;
}

Public files (public/avatar, public/thumbnail, public/landing) always return expiresAt: null. The frontend can cache these URLs forever — they are stable, permanent URLs that do not expire.