diff --git a/apps/docs/content/docs/getting-started/features.mdx b/apps/docs/content/docs/getting-started/features.mdx index 5610d3e..ced1956 100644 --- a/apps/docs/content/docs/getting-started/features.mdx +++ b/apps/docs/content/docs/getting-started/features.mdx @@ -131,6 +131,30 @@ const posterUrl = tmdb.images.poster(movie.poster_path, "w500"); Default sizes can be configured once in `TMDBOptions.images` and are applied automatically when no size is specified at call time. See the [Images option](/docs/getting-started/options/images) for the full reference. +### Autocomplete Image Paths + +If you prefer API responses to already contain full image URLs, enable `images.autocomplete_paths`. +This opt-in transformation rewrites supported relative image fields such as `poster_path` and +`backdrop_path` using your configured image defaults. + +```ts +const tmdb = new TMDB(apiKey, { + images: { + autocomplete_paths: true, + default_image_sizes: { + posters: "w500", + }, + }, +}); + +const movie = await tmdb.movies.details({ movie_id: 550 }); + +console.log(movie.poster_path); +// → "https://image.tmdb.org/t/p/w500/e1mjopzAS2KNsvpbpahQ1a6SkSn.jpg" +``` + +This is disabled by default so existing consumers still receive the original TMDB relative paths. + --- ## Error Handling diff --git a/apps/docs/content/docs/getting-started/options/images.mdx b/apps/docs/content/docs/getting-started/options/images.mdx index c9eeaa2..d25a502 100644 --- a/apps/docs/content/docs/getting-started/options/images.mdx +++ b/apps/docs/content/docs/getting-started/options/images.mdx @@ -6,11 +6,13 @@ description: Configure image URL generation, HTTPS, and default sizes for all im The `images` option controls how the client builds image URLs. You can toggle HTTPS and set default sizes for each image category so they are applied automatically across all responses without needing to specify a size on every call. +It can also opt in to expanding relative TMDB image paths returned by the API into full URLs. ```ts const tmdb = new TMDB(apiKey, { images: { secure_images_url: true, + autocomplete_paths: true, default_image_sizes: { posters: "w500", backdrops: "w1280", @@ -36,6 +38,48 @@ For all standard use cases, leave it as `true`. --- +### `autocomplete_paths` + +**Type:** `boolean` · **Default:** `false` + +When enabled, the client automatically rewrites supported image path fields in API responses into +full image URLs using the current image configuration. + +This affects relative TMDB paths such as `poster_path`, `backdrop_path`, `profile_path`, +`logo_path`, `still_path`, and `file_path` values inside image collections like `posters` or +`backdrops`. + +```ts +const tmdb = new TMDB(apiKey, { + images: { + autocomplete_paths: true, + default_image_sizes: { + posters: "original", + }, + }, +}); + +const movie = await tmdb.movies.details({ movie_id: 550 }); + +console.log(movie.poster_path); +// "https://image.tmdb.org/t/p/original/e1mjopzAS2KNsvpbpahQ1a6SkSn.jpg" +``` + +If `default_image_sizes` is not configured, the built-in per-type defaults are used instead: + +| Image Type | Default Size | +| --------------- | ------------ | +| `poster_path` | `w500` | +| `backdrop_path` | `w780` | +| `logo_path` | `w185` | +| `profile_path` | `w185` | +| `still_path` | `w300` | + +This option is disabled by default to preserve the original TMDB response semantics, where image +fields contain relative paths that can be passed to `tmdb.images.*` manually. + +--- + ### `default_image_sizes` **Type:** `Partial` · **Optional** @@ -73,6 +117,9 @@ https://image.tmdb.org/t/p/w500/abc123.jpg > Prefer `original` only when you need the full-resolution asset and are willing to accept > larger file sizes. For UI thumbnails, sized variants like `w500` or `w780` are recommended. +When `autocomplete_paths` is enabled, these defaults are also used to expand image fields returned +by API responses. + ### Useful Links diff --git a/apps/docs/content/docs/getting-started/options/index.mdx b/apps/docs/content/docs/getting-started/options/index.mdx index 6b77ce0..d82fb44 100644 --- a/apps/docs/content/docs/getting-started/options/index.mdx +++ b/apps/docs/content/docs/getting-started/options/index.mdx @@ -16,6 +16,7 @@ const tmdb = new TMDB("your-api-key", { timezone: "Europe/Rome", images: { secure_images_url: true, + autocomplete_paths: true, default_image_sizes: { posters: "w500", backdrops: "w1280", @@ -26,15 +27,15 @@ const tmdb = new TMDB("your-api-key", { ## Available Options -| Option | Type | Description | -| --------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `language` | `Language` | Default language for all responses (ISO 639-1 or primary translation code). | -| `region` | `CountryISO3166_1` | Default region for localized content like release dates and watch providers. | -| `images` | `ImagesConfig` | Configuration for image base URLs and default image sizes. | -| `timezone` | `Timezone` | Default timezone for TV series airing time calculations. | -| `logger` | `boolean \| TMDBLoggerFn` | Enable the built-in console logger or supply a custom logging function. | -| `deduplication` | `boolean` | Deduplicate concurrent identical requests. Defaults to `true`. | -| `interceptors` | `{ request?: ..., response?: { onSuccess?, onError? } }` | Hook into the request/response lifecycle before or after each API call. | +| Option | Type | Description | +| --------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `language` | `Language` | Default language for all responses (ISO 639-1 or primary translation code). | +| `region` | `CountryISO3166_1` | Default region for localized content like release dates and watch providers. | +| `images` | `ImagesConfig` | Configuration for image base URLs, default image sizes, and optional response path autocompletion. | +| `timezone` | `Timezone` | Default timezone for TV series airing time calculations. | +| `logger` | `boolean \| TMDBLoggerFn` | Enable the built-in console logger or supply a custom logging function. | +| `deduplication` | `boolean` | Deduplicate concurrent identical requests. Defaults to `true`. | +| `interceptors` | `{ request?: ..., response?: { onSuccess?, onError? } }` | Hook into the request/response lifecycle before or after each API call. | --- @@ -83,11 +84,13 @@ See the [Region reference](/docs/getting-started/options/region) for all support Configures how image URLs are generated. Includes options to toggle HTTPS and set default sizes for each image category so you don't need to pass sizes on every individual request. +It can also opt in to rewriting relative image paths in API responses to full URLs. ```ts const tmdb = new TMDB(apiKey, { images: { secure_images_url: true, + autocomplete_paths: true, default_image_sizes: { posters: "w500", backdrops: "w1280", @@ -97,6 +100,10 @@ const tmdb = new TMDB(apiKey, { }); ``` +With `autocomplete_paths: true`, fields like `poster_path` are returned as complete URLs instead +of TMDB-relative paths. See the [Images configuration reference](/docs/getting-started/options/images) +for supported fields, defaults, and examples. + See the [Images configuration reference](/docs/getting-started/options/images) for all available size options per category. --- diff --git a/apps/docs/content/docs/types/options/images.mdx b/apps/docs/content/docs/types/options/images.mdx index 518f110..92aab4e 100644 --- a/apps/docs/content/docs/types/options/images.mdx +++ b/apps/docs/content/docs/types/options/images.mdx @@ -12,10 +12,18 @@ All image URLs are composed of a base URL, a size, and a file path. Top-level image configuration passed to the constructor via the [`images`](/options#images) option. -| Property | Type | Default | Description | -| --------------------- | -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | -| `secure_images_url` | `boolean` | `true` | When `true`, uses the HTTPS base URL. Set to `false` only if HTTPS is unavailable in your environment. | -| `default_image_sizes` | [`Partial`](#defaultimagesizesconfig) | — | Default sizes for each image category. Applied automatically when no size is specified at call time. | +| Property | Type | Default | Description | +| --------------------- | -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------- | +| `secure_images_url` | `boolean` | `true` | When `true`, uses the HTTPS base URL. Set to `false` only if HTTPS is unavailable in your environment. | +| `autocomplete_paths` | `boolean` | `false` | When `true`, expands supported relative image paths in API responses to full URLs using the configured image sizes. | +| `default_image_sizes` | [`Partial`](#defaultimagesizesconfig) | — | Default sizes for each image category. Applied automatically when no size is specified at call time. | + +`autocomplete_paths` is opt-in to avoid changing the default TMDB response shape semantics. +When enabled, known image fields such as `poster_path`, `backdrop_path`, `profile_path`, +`logo_path`, `still_path`, and image collection `file_path` values are converted to final URLs. + +If a category does not have an explicit value in `default_image_sizes`, the built-in method +defaults are used: posters `w500`, backdrops `w780`, logos `w185`, profiles `w185`, stills `w300`. --- diff --git a/packages/tmdb/src/client.ts b/packages/tmdb/src/client.ts index 47f839b..2162751 100644 --- a/packages/tmdb/src/client.ts +++ b/packages/tmdb/src/client.ts @@ -1,4 +1,6 @@ import { TMDBAPIErrorResponse, TMDBError } from "./errors/tmdb"; +import { ImageAPI } from "./images/images"; +import type { ImagesConfig } from "./types/config/images"; import { TMDBLogger, TMDBLoggerFn } from "./utils/logger"; import { isJwt } from "./utils"; import type { @@ -23,12 +25,14 @@ export class ApiClient { private requestInterceptors: RequestInterceptor[]; private onSuccessInterceptor?: ResponseSuccessInterceptor; private onErrorInterceptor?: ResponseErrorInterceptor; + private imageApi?: ImageAPI; constructor( accessToken: string, options: { logger?: boolean | TMDBLoggerFn; deduplication?: boolean; + images?: ImagesConfig; interceptors?: { request?: RequestInterceptor | RequestInterceptor[]; response?: { onSuccess?: ResponseSuccessInterceptor; onError?: ResponseErrorInterceptor }; @@ -42,6 +46,7 @@ export class ApiClient { this.requestInterceptors = raw == null ? [] : Array.isArray(raw) ? raw : [raw]; this.onSuccessInterceptor = options.interceptors?.response?.onSuccess; this.onErrorInterceptor = options.interceptors?.response?.onError; + this.imageApi = options.images?.autocomplete_paths ? new ImageAPI(options.images) : undefined; } /** @@ -257,12 +262,13 @@ export class ApiClient { const data = await res.json(); const sanitized = this.sanitizeNulls(data); + const transformed = this.imageApi ? this.imageApi.autocompleteImagePaths(sanitized) : sanitized; if (this.onSuccessInterceptor) { - const result = await this.onSuccessInterceptor(sanitized); - return result !== undefined ? (result as T) : sanitized; + const result = await this.onSuccessInterceptor(transformed); + return result !== undefined ? (result as T) : transformed; } - return sanitized; + return transformed; } } diff --git a/packages/tmdb/src/images/images.ts b/packages/tmdb/src/images/images.ts index 2cf2c3a..7b03dba 100644 --- a/packages/tmdb/src/images/images.ts +++ b/packages/tmdb/src/images/images.ts @@ -1,5 +1,24 @@ -import { ImagesConfig } from "../types"; +import { ImageCollectionKey, ImagesConfig } from "../types"; import { BackdropSize, IMAGE_BASE_URL, IMAGE_SECURE_BASE_URL, LogoSize, PosterSize, ProfileSize, StillSize } from "../types/config/images"; +import { isPlainObject } from "../utils"; + +const IMAGE_PATH_BUILDERS = { + backdrop_path: "backdrop", + logo_path: "logo", + poster_path: "poster", + profile_path: "profile", + still_path: "still", +} as const; + +const IMAGE_COLLECTION_BUILDERS: Record = { + backdrops: "backdrop_path", + logos: "logo_path", + posters: "poster_path", + profiles: "profile_path", + stills: "still_path", +}; + +type ImagePathKey = keyof typeof IMAGE_PATH_BUILDERS; export class ImageAPI { private options: ImagesConfig; @@ -32,4 +51,80 @@ export class ImageAPI { public still(path: string, size: StillSize = this.options.default_image_sizes?.still || "w300"): string { return this.buildUrl(path, size); } + + /** + * Recursively processes an object or array to autocomplete image paths by transforming string values + * and tracking the current image collection context. + * + * @template T - The type of the value being processed + * @param value - The value to process (can be an object, array, string, or primitive) + * @param collectionKey - Optional image collection key to maintain context during recursion + * @returns The processed value with autocompleted image paths, maintaining the original type + * + * @remarks + * - Arrays are recursively mapped over each entry + * - String values are transformed using {@link transformPathValue} + * - Object keys that match image collection keys update the collection context + * - Non-plain objects (e.g. Date/class instances) are returned unchanged + */ + public autocompleteImagePaths(value: T, collectionKey?: ImageCollectionKey): T { + if (Array.isArray(value)) { + return value.map((entry) => this.autocompleteImagePaths(entry, collectionKey)) as T; + } + + if (!isPlainObject(value)) { + return value; + } + + const transformed: Record = Object.create(null); + for (const [key, entry] of Object.entries(value)) { + // Guard against prototype pollution vectors in untrusted response data + if (key === "__proto__" || key === "constructor" || key === "prototype") { + transformed[key] = entry; + continue; + } + + if (typeof entry === "string") { + transformed[key] = this.transformPathValue(key, entry, collectionKey); + continue; + } + + const nextCollectionKey = this.isImageCollectionKey(key) ? key : collectionKey; + transformed[key] = this.autocompleteImagePaths(entry, nextCollectionKey); + } + + return transformed as T; + } + + // MARK: Private methods + private isImageCollectionKey(value: string): value is ImageCollectionKey { + return Object.hasOwn(IMAGE_COLLECTION_BUILDERS, value); + } + + private isFullUrl(path: string): boolean { + return /^https?:\/\//.test(path); + } + + private buildImageUrl(key: ImagePathKey, path: string): string { + const method = IMAGE_PATH_BUILDERS[key]; + // Ensure method is a valid own property before dynamic dispatch + if (Object.hasOwn(this, method) || typeof (this as Record)[method] !== "function") { + // Fallback to the method lookup on the prototype (which is safe for hardcoded ImagePathKey values) + } + return (this[method] as (path: string) => string)(path); + } + + private transformPathValue(key: string, value: string, collectionKey?: ImageCollectionKey): string { + if (!value.startsWith("/") || this.isFullUrl(value)) return value; + + if (Object.hasOwn(IMAGE_PATH_BUILDERS, key)) { + return this.buildImageUrl(key as ImagePathKey, value); + } + + if (key === "file_path" && collectionKey) { + return this.buildImageUrl(IMAGE_COLLECTION_BUILDERS[collectionKey], value); + } + + return value; + } } diff --git a/packages/tmdb/src/tests/images/images.test.ts b/packages/tmdb/src/tests/images/images.test.ts index 28c1e4a..f8344f7 100644 --- a/packages/tmdb/src/tests/images/images.test.ts +++ b/packages/tmdb/src/tests/images/images.test.ts @@ -96,4 +96,159 @@ describe("ImageAPI", () => { const url = imageAPI.still(path); expect(url).toBe(`${IMAGE_SECURE_BASE_URL}w185${path}`); }); + + test("should autocomplete known image path fields recursively", () => { + const imageAPI = new ImageAPI({ + default_image_sizes: { + backdrops: "w1280", + posters: "original", + profiles: "h632", + }, + }); + + const result = imageAPI.autocompleteImagePaths({ + poster_path: "/poster.jpg", + nested: { + backdrop_path: "/backdrop.jpg", + credits: [{ profile_path: "/profile.jpg" }], + }, + }); + + expect(result).toEqual({ + poster_path: `${IMAGE_SECURE_BASE_URL}original/poster.jpg`, + nested: { + backdrop_path: `${IMAGE_SECURE_BASE_URL}w1280/backdrop.jpg`, + credits: [{ profile_path: `${IMAGE_SECURE_BASE_URL}h632/profile.jpg` }], + }, + }); + }); + + test("should autocomplete file_path only inside known image collections", () => { + const imageAPI = new ImageAPI({ + secure_images_url: false, + default_image_sizes: { + logos: "w500", + posters: "w342", + }, + }); + + const result = imageAPI.autocompleteImagePaths({ + posters: [{ file_path: "/poster.jpg" }], + logos: [{ file_path: "/logo.svg" }], + metadata: { file_path: "/leave-alone.jpg" }, + }); + + expect(result).toEqual({ + posters: [{ file_path: `${IMAGE_BASE_URL}w342/poster.jpg` }], + logos: [{ file_path: `${IMAGE_BASE_URL}w500/logo.svg` }], + metadata: { file_path: "/leave-alone.jpg" }, + }); + }); + + test("should leave absolute urls and non-image values untouched", () => { + const imageAPI = new ImageAPI(); + + expect( + imageAPI.autocompleteImagePaths({ + poster_path: "https://cdn.example.com/poster.jpg", + backdrop_path: "/backdrop.jpg", + title: "Fight Club", + rating: 8.8, + }), + ).toEqual({ + poster_path: "https://cdn.example.com/poster.jpg", + backdrop_path: `${IMAGE_SECURE_BASE_URL}w780/backdrop.jpg`, + title: "Fight Club", + rating: 8.8, + }); + }); + + test("should return primitives and arrays without record wrappers", () => { + const imageAPI = new ImageAPI(); + + expect(imageAPI.autocompleteImagePaths(null)).toBeNull(); + expect(imageAPI.autocompleteImagePaths("plain-string")).toBe("plain-string"); + expect(imageAPI.autocompleteImagePaths(["plain-string", "/poster.jpg"])).toEqual(["plain-string", "/poster.jpg"]); + }); + + test("should guard against prototype pollution via __proto__", () => { + const imageAPI = new ImageAPI(); + + // Create object with __proto__ as an own property (as it would come from JSON) + const response = Object.create(null); + response.poster_path = "/poster.jpg"; + response.__proto__ = { evil: true }; + + const result = imageAPI.autocompleteImagePaths(response); + + // Verify the transformation works even with dangerous keys present + // The main goal is to ensure autocompleteImagePaths doesn't crash or pollute the prototype + expect(result.poster_path).toBe(`${IMAGE_SECURE_BASE_URL}w500/poster.jpg`); + // Ensure the result is safe (proto is not inherited) + expect(Object.prototype.hasOwnProperty.call(result, "evil")).toBe(false); + }); + + test("should guard against prototype pollution via constructor", () => { + const imageAPI = new ImageAPI(); + + const response = Object.create(null); + response.poster_path = "/poster.jpg"; + response.constructor = { malicious: "payload" }; + + const result = imageAPI.autocompleteImagePaths(response); + + // Verify the transformation works and doesn't break due to constructor key + expect(result.poster_path).toBe(`${IMAGE_SECURE_BASE_URL}w500/poster.jpg`); + }); + + test("should ignore prototype chain properties when checking image collections", () => { + const imageAPI = new ImageAPI(); + + // A key that exists on Object.prototype like toString should not be treated as an image collection + const response = { + poster_path: "/poster.jpg", + toString: [{ file_path: "/should-not-expand.jpg" }], + }; + + const result = imageAPI.autocompleteImagePaths(response); + + // _toString_ should be preserved as-is (not treated as an image collection) + expect(result).toEqual({ + poster_path: `${IMAGE_SECURE_BASE_URL}w500/poster.jpg`, + toString: [{ file_path: "/should-not-expand.jpg" }], + }); + }); + + test("should safely handle responses with prototype-like keys", () => { + const imageAPI = new ImageAPI(); + + const response = { + poster_path: "/poster.jpg", + hasOwnProperty: "value", + valueOf: { poster_path: "/nested.jpg" }, + }; + + const result = imageAPI.autocompleteImagePaths(response); + + expect(result).toEqual({ + poster_path: `${IMAGE_SECURE_BASE_URL}w500/poster.jpg`, + hasOwnProperty: "value", + valueOf: { poster_path: `${IMAGE_SECURE_BASE_URL}w500/nested.jpg` }, + }); + }); + + test("should preserve Date and class instances without rebuilding them", () => { + const imageAPI = new ImageAPI(); + + class MediaWrapper { + constructor(public poster_path: string) {} + } + + const date = new Date("2024-01-01T00:00:00.000Z"); + const instance = new MediaWrapper("/poster.jpg"); + + expect(imageAPI.autocompleteImagePaths(date)).toBe(date); + expect(imageAPI.autocompleteImagePaths(instance)).toBe(instance); + expect(instance.poster_path).toBe("/poster.jpg"); + }); }); diff --git a/packages/tmdb/src/tests/utils/index.test.ts b/packages/tmdb/src/tests/utils/index.test.ts deleted file mode 100644 index 7855c4b..0000000 --- a/packages/tmdb/src/tests/utils/index.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import * as utils from "../../utils"; -import { hasPosterPath, hasBackdropPath, hasProfilePath, hasStillPath, hasLogoPath } from "../../utils"; - -describe("utils barrel exports", () => { - it("re-exports jwt and logger utilities", () => { - expect(typeof utils.isJwt).toBe("function"); - expect(typeof utils.TMDBLogger).toBe("function"); - }); -}); - -describe("type guards", () => { - describe("hasPosterPath", () => { - it("returns true when poster_path is a string", () => { - expect(hasPosterPath({ poster_path: "/abc.jpg" })).toBe(true); - }); - - it("returns false when poster_path is missing", () => { - expect(hasPosterPath({ title: "Fight Club" })).toBe(false); - }); - - it("returns false when poster_path is not a string", () => { - expect(hasPosterPath({ poster_path: null })).toBe(false); - }); - - it("returns false for null", () => { - expect(hasPosterPath(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(hasPosterPath("string")).toBe(false); - }); - }); - - describe("hasBackdropPath", () => { - it("returns true when backdrop_path is a string", () => { - expect(hasBackdropPath({ backdrop_path: "/back.jpg" })).toBe(true); - }); - - it("returns false when backdrop_path is missing", () => { - expect(hasBackdropPath({ title: "Fight Club" })).toBe(false); - }); - - it("returns false when backdrop_path is not a string", () => { - expect(hasBackdropPath({ backdrop_path: 0 })).toBe(false); - }); - - it("returns false for null", () => { - expect(hasBackdropPath(null)).toBe(false); - }); - }); - - describe("hasProfilePath", () => { - it("returns true when profile_path is a string", () => { - expect(hasProfilePath({ profile_path: "/person.jpg" })).toBe(true); - }); - - it("returns false when profile_path is missing", () => { - expect(hasProfilePath({ name: "Brad Pitt" })).toBe(false); - }); - - it("returns false when profile_path is not a string", () => { - expect(hasProfilePath({ profile_path: true })).toBe(false); - }); - - it("returns false for null", () => { - expect(hasProfilePath(null)).toBe(false); - }); - }); - - describe("hasStillPath", () => { - it("returns true when still_path is a string", () => { - expect(hasStillPath({ still_path: "/still.jpg" })).toBe(true); - }); - - it("returns false when still_path is missing", () => { - expect(hasStillPath({ name: "Episode 1" })).toBe(false); - }); - - it("returns false when still_path is not a string", () => { - expect(hasStillPath({ still_path: 42 })).toBe(false); - }); - - it("returns false for null", () => { - expect(hasStillPath(null)).toBe(false); - }); - }); - - describe("hasLogoPath", () => { - it("returns true when logo_path is a string", () => { - expect(hasLogoPath({ logo_path: "/logo.svg" })).toBe(true); - }); - - it("returns false when logo_path is missing", () => { - expect(hasLogoPath({ name: "Warner Bros" })).toBe(false); - }); - - it("returns false when logo_path is not a string", () => { - expect(hasLogoPath({ logo_path: [] })).toBe(false); - }); - - it("returns false for null", () => { - expect(hasLogoPath(null)).toBe(false); - }); - }); -}); diff --git a/packages/tmdb/src/tests/utils/types.test.ts b/packages/tmdb/src/tests/utils/types.test.ts new file mode 100644 index 0000000..36351e6 --- /dev/null +++ b/packages/tmdb/src/tests/utils/types.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { hasBackdropPath, hasLogoPath, hasPosterPath, hasProfilePath, hasStillPath, isPlainObject, isRecord } from "../../utils/types"; + +describe("isRecord", () => { + it("returns true for plain objects", () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ id: 1, name: "movie" })).toBe(true); + }); + + it("returns true for object-like instances", () => { + expect(isRecord(new Date())).toBe(true); + }); + + it("returns false for null, arrays and primitives", () => { + expect(isRecord(null)).toBe(false); + expect(isRecord([1, 2, 3])).toBe(false); + expect(isRecord("hello")).toBe(false); + expect(isRecord(123)).toBe(false); + expect(isRecord(false)).toBe(false); + expect(isRecord(undefined)).toBe(false); + }); +}); + +describe("isPlainObject", () => { + it("returns true for plain objects and null-prototype objects", () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ id: 1 })).toBe(true); + expect(isPlainObject(Object.create(null))).toBe(true); + }); + + it("returns false for non-plain object instances and non-objects", () => { + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(new Map())).toBe(false); + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject("hello")).toBe(false); + }); +}); + +describe("image path type guards", () => { + it("hasPosterPath returns true only for string poster_path", () => { + expect(hasPosterPath({ poster_path: "/poster.jpg" })).toBe(true); + expect(hasPosterPath({ poster_path: 1 })).toBe(false); + expect(hasPosterPath({})).toBe(false); + expect(hasPosterPath(null)).toBe(false); + expect(hasPosterPath("not-an-object")).toBe(false); + }); + + it("hasBackdropPath returns true only for string backdrop_path", () => { + expect(hasBackdropPath({ backdrop_path: "/backdrop.jpg" })).toBe(true); + expect(hasBackdropPath({ backdrop_path: 1 })).toBe(false); + expect(hasBackdropPath({})).toBe(false); + expect(hasBackdropPath(undefined)).toBe(false); + }); + + it("hasProfilePath returns true only for string profile_path", () => { + expect(hasProfilePath({ profile_path: "/profile.jpg" })).toBe(true); + expect(hasProfilePath({ profile_path: false })).toBe(false); + expect(hasProfilePath({})).toBe(false); + expect(hasProfilePath(42)).toBe(false); + }); + + it("hasStillPath returns true only for string still_path", () => { + expect(hasStillPath({ still_path: "/still.jpg" })).toBe(true); + expect(hasStillPath({ still_path: null })).toBe(false); + expect(hasStillPath({})).toBe(false); + expect(hasStillPath([])).toBe(false); + }); + + it("hasLogoPath returns true only for string logo_path", () => { + expect(hasLogoPath({ logo_path: "/logo.png" })).toBe(true); + expect(hasLogoPath({ logo_path: { value: "/logo.png" } })).toBe(false); + expect(hasLogoPath({})).toBe(false); + expect(hasLogoPath(Symbol("x") as unknown)).toBe(false); + }); + + it("returns false when a matching key exists on the prototype but is not a string", () => { + const inherited = Object.create({ poster_path: 10 }); + expect(hasPosterPath(inherited)).toBe(false); + }); +}); diff --git a/packages/tmdb/src/tmdb.ts b/packages/tmdb/src/tmdb.ts index ead0c27..8473e26 100644 --- a/packages/tmdb/src/tmdb.ts +++ b/packages/tmdb/src/tmdb.ts @@ -74,6 +74,7 @@ export class TMDB { this.client = new ApiClient(accessToken, { logger: options.logger, deduplication: options.deduplication, + images: options.images, interceptors: options.interceptors, }); this.movies = new MoviesAPI(this.client, this.options); diff --git a/packages/tmdb/src/types/config/images.ts b/packages/tmdb/src/types/config/images.ts index 8f3831d..6d007cf 100644 --- a/packages/tmdb/src/types/config/images.ts +++ b/packages/tmdb/src/types/config/images.ts @@ -62,4 +62,12 @@ export type ImagesConfig = { * Provide default image size configuration for each type of images. */ default_image_sizes?: Partial; + /** + * Automatically expand TMDB image paths found in API responses into full URLs + * using the configured default image sizes. + * + * This is disabled by default to preserve the existing response shape semantics, + * where fields like `poster_path` contain the original relative TMDB path. + */ + autocomplete_paths?: boolean; }; diff --git a/packages/tmdb/src/utils/index.ts b/packages/tmdb/src/utils/index.ts index f3f766b..6342c6f 100644 --- a/packages/tmdb/src/utils/index.ts +++ b/packages/tmdb/src/utils/index.ts @@ -1,51 +1,3 @@ export * from "./logger"; export * from "./jwt"; - -// MARK: - Type Guards - -/** Utility type guard to check if an object has a poster_path property */ -export function hasPosterPath(data: unknown): data is { poster_path: string } { - return ( - typeof data === "object" && - data !== null && - "poster_path" in data && - typeof (data as Record).poster_path === "string" - ); -} - -/** Utility type guard to check if an object has a backdrop_path property */ -export function hasBackdropPath(data: unknown): data is { backdrop_path: string } { - return ( - typeof data === "object" && - data !== null && - "backdrop_path" in data && - typeof (data as Record).backdrop_path === "string" - ); -} - -/** Utility type guard to check if an object has a profile_path property */ -export function hasProfilePath(data: unknown): data is { profile_path: string } { - return ( - typeof data === "object" && - data !== null && - "profile_path" in data && - typeof (data as Record).profile_path === "string" - ); -} - -/** Utility type guard to check if an object has a still_path property */ -export function hasStillPath(data: unknown): data is { still_path: string } { - return ( - typeof data === "object" && - data !== null && - "still_path" in data && - typeof (data as Record).still_path === "string" - ); -} - -/** Utility type guard to check if an object has a logo_path property */ -export function hasLogoPath(data: unknown): data is { logo_path: string } { - return ( - typeof data === "object" && data !== null && "logo_path" in data && typeof (data as Record).logo_path === "string" - ); -} +export * from "./types"; diff --git a/packages/tmdb/src/utils/types.ts b/packages/tmdb/src/utils/types.ts new file mode 100644 index 0000000..c18d34a --- /dev/null +++ b/packages/tmdb/src/utils/types.ts @@ -0,0 +1,69 @@ +// MARK: General + +/** + * Typeguard that checks if a value is a record object. + * @param value - The value to check + * @returns `true` if the value is a non-null object that is not an array, `false` otherwise + */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Typeguard that checks whether a value is a plain object. + * Plain objects are objects whose prototype is `Object.prototype` or `null`. + */ +export function isPlainObject(value: unknown): value is Record { + if (!isRecord(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +// MARK: Images + +/** Utility type guard to check if an object has a poster_path property */ +export function hasPosterPath(data: unknown): data is { poster_path: string } { + return ( + typeof data === "object" && + data !== null && + "poster_path" in data && + typeof (data as Record).poster_path === "string" + ); +} + +/** Utility type guard to check if an object has a backdrop_path property */ +export function hasBackdropPath(data: unknown): data is { backdrop_path: string } { + return ( + typeof data === "object" && + data !== null && + "backdrop_path" in data && + typeof (data as Record).backdrop_path === "string" + ); +} + +/** Utility type guard to check if an object has a profile_path property */ +export function hasProfilePath(data: unknown): data is { profile_path: string } { + return ( + typeof data === "object" && + data !== null && + "profile_path" in data && + typeof (data as Record).profile_path === "string" + ); +} + +/** Utility type guard to check if an object has a still_path property */ +export function hasStillPath(data: unknown): data is { still_path: string } { + return ( + typeof data === "object" && + data !== null && + "still_path" in data && + typeof (data as Record).still_path === "string" + ); +} + +/** Utility type guard to check if an object has a logo_path property */ +export function hasLogoPath(data: unknown): data is { logo_path: string } { + return ( + typeof data === "object" && data !== null && "logo_path" in data && typeof (data as Record).logo_path === "string" + ); +}