From 58e5831ca110e451a99f04844de8babc61710bf3 Mon Sep 17 00:00:00 2001 From: juliet Date: Fri, 29 May 2026 12:00:27 -0400 Subject: [PATCH] Wire MEDIA scalar through server + client UI (#632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Wire MEDIA scalar through server + client UI Builds on #605 (which added MEDIA to @roostorg/coop-types v2.3.0) to make MEDIA actually usable end-to-end: Server: - Adds MEDIA to ScalarType / FieldType / SignalInputType GraphQL enums. - Adds a coerceMediaInput handler that validates the URL and resolves the media kind from the pathname extension. Conservative table — only unambiguous extensions are mapped, so e.g. .ogg stays unresolved. - Exports detectMediaKindFromUrl so other consumers can run the same detection without going through coerce. - Test arbitrary + 33 unit tests covering happy paths (image / video / audio extensions), unresolved cases (unknown ext, no ext, ambiguous containers), and rejection paths (non-string input, blocked / unsupported schemes, query-string extension confusion). Client: - MEDIA appears in the ItemType form field-type dropdown automatically (the dropdown iterates Object.values(GQLScalarType)). - ManualReviewJobFieldsComponent renders a MEDIA value as image / video / audio per the stored mediaType, falling back to a link when detection didn't resolve. AUDIO/IMAGE/VIDEO branches unchanged. - Updates the exhaustive switches in RuleTestModal, RuleInsightsSamplesTable, generateFakeScalarFieldValue, signal comparators, and the rules-side isMediaType helper so MEDIA is handled everywhere AUDIO/IMAGE/VIDEO were. Closes #418 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(client): return MEDIA fake value as raw URL string generateFakeScalarFieldValue produces *input* (pre-coercion) values for code samples — AUDIO/IMAGE/VIDEO all return raw URL strings, not the post-coerce `{ url }` shape. MEDIA followed the same input contract: a raw URL. This also fixes the CI lint failure on PR #632. Returning an object for MEDIA polluted the function's union return type: TS narrowed RELATED_ITEM's branch to `{ id, typeId, name, url?: undefined, mediaType?: undefined }` and the optional-undefined props tripped the strict JsonValue index signature in itemTypeCodeSampleUtils.ts. Co-Authored-By: Claude Opus 4.7 (1M context) * Address CodeRabbit feedback on MEDIA wiring - ManualReviewJobFieldsComponent: include MEDIA in the container layout condition that picks horizontal flex for media value types, so an Array gets the same horizontal scroll layout as Array / VIDEO / AUDIO. - itemTypeUtils.generateFakeScalarFieldValue: emit a MEDIA fake URL with a real .jpg / .mp4 / .mp3 extension, cycling between kinds. The previous picsum URL had no extension, so the server's coercion would set mediaType to null and the IMAGE / VIDEO / AUDIO render paths never got fake-data coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- client/package-lock.json | 8 +- client/package.json | 10 +-- client/src/graphql/generated.ts | 3 + client/src/models/signal.ts | 1 + .../dashboard/item_types/itemTypeUtils.ts | 9 +++ .../v2/ManualReviewJobFieldsComponent.tsx | 78 +++++++++++++++++- .../dashboard/rules/info/RuleTestModal.tsx | 2 + .../insights/RuleInsightsSamplesTable.tsx | 1 + client/src/webpages/dashboard/rules/types.ts | 3 +- server/graphql/generated.ts | 3 + server/graphql/schema.ts | 3 + server/package-lock.json | 8 +- server/package.json | 4 +- .../fieldTypeHandlers.test.ts | 80 ++++++++++++++++++- .../fieldTypeHandlers.ts | 68 ++++++++++++++++ server/test/arbitraries/ContentType.ts | 11 +++ 16 files changed, 274 insertions(+), 18 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index c015fc7..c64d621 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -29,7 +29,7 @@ "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@roostorg/coop-types": "^2.2.0", + "@roostorg/coop-types": "^2.3.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", @@ -3146,9 +3146,9 @@ ] }, "node_modules/@roostorg/coop-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@roostorg/coop-types/-/coop-types-2.2.0.tgz", - "integrity": "sha512-qq7WmaGIPZo/bzJfhIme5yVU/SRtFeifEzq3NJi/PFjOEh2lgj30vUZVCeZHsi8sEjmj63sFeUyrM+kiHZKljg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@roostorg/coop-types/-/coop-types-2.3.0.tgz", + "integrity": "sha512-aOHomJIoqNUUoHxewxjOe6x2Y7ALcVgweUX7SxD18DwfylYK1KJZi4Q5nZB0SMm6q7V5H6btJfo4jRQMLxUT+g==", "license": "ISC", "dependencies": { "date-fns": "^2.29.3", diff --git a/client/package.json b/client/package.json index 210874b..30521c4 100644 --- a/client/package.json +++ b/client/package.json @@ -36,7 +36,7 @@ "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@roostorg/coop-types": "^2.2.0", + "@roostorg/coop-types": "^2.3.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", @@ -88,15 +88,15 @@ "@testing-library/jest-dom": "^5.11.9", "@testing-library/react": "^11.2.3", "@testing-library/user-event": "^12.6.0", + "@types/google.maps": "^3.48.7", + "@types/latlon-geohash": "^2.0.0", "@types/lodash": "^4.14.181", + "@types/papaparse": "^5.3.14", "@types/react": "^18.0.0", "@types/react-beautiful-dnd": "^13.1.4", + "@types/react-csv": "^1.1.3", "@types/react-dom": "^18.0.0", "@types/react-router-hash-link": "^2.4.5", - "@types/google.maps": "^3.48.7", - "@types/latlon-geohash": "^2.0.0", - "@types/papaparse": "^5.3.14", - "@types/react-csv": "^1.1.3", "@types/react-syntax-highlighter": "^13.5.2", "@types/react-table": "^7.7.5", "@types/recharts": "^1.8.23", diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index e5b5c66..bea697c 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -1277,6 +1277,7 @@ export const GQLFieldType = { Image: 'IMAGE', IpAddress: 'IP_ADDRESS', Map: 'MAP', + Media: 'MEDIA', Number: 'NUMBER', PolicyId: 'POLICY_ID', RelatedItem: 'RELATED_ITEM', @@ -4077,6 +4078,7 @@ export const GQLScalarType = { Id: 'ID', Image: 'IMAGE', IpAddress: 'IP_ADDRESS', + Media: 'MEDIA', Number: 'NUMBER', PolicyId: 'POLICY_ID', RelatedItem: 'RELATED_ITEM', @@ -4217,6 +4219,7 @@ export const GQLSignalInputType = { Id: 'ID', Image: 'IMAGE', IpAddress: 'IP_ADDRESS', + Media: 'MEDIA', Number: 'NUMBER', PolicyId: 'POLICY_ID', RelatedItem: 'RELATED_ITEM', diff --git a/client/src/models/signal.ts b/client/src/models/signal.ts index d0e77e0..31711a5 100644 --- a/client/src/models/signal.ts +++ b/client/src/models/signal.ts @@ -112,6 +112,7 @@ export function outputTypeToComparators(outputType: GQLSignalOutputType) { case GQLScalarType.Audio: case GQLScalarType.Image: case GQLScalarType.Video: + case GQLScalarType.Media: case GQLScalarType.Geohash: case GQLScalarType.Boolean: case GQLScalarType.RelatedItem: diff --git a/client/src/webpages/dashboard/item_types/itemTypeUtils.ts b/client/src/webpages/dashboard/item_types/itemTypeUtils.ts index 2d60bd2..03bf3ef 100644 --- a/client/src/webpages/dashboard/item_types/itemTypeUtils.ts +++ b/client/src/webpages/dashboard/item_types/itemTypeUtils.ts @@ -225,6 +225,15 @@ export function generateFakeScalarFieldValue(fieldType: ScalarType) { return `https://url.com/some-path/${Math.floor(100 * Math.random())}`; case 'IP_ADDRESS': return `192.0.2.${Math.floor(255 * Math.random())}`; + case 'MEDIA': { + // Like AUDIO/IMAGE/VIDEO, the fake value is the *input* (pre-coercion) form + // — a raw URL string. The server's MEDIA coercion resolves the kind from + // the extension, so cycle through one of each so the IMAGE/VIDEO/AUDIO + // render paths all get fake-data coverage. + const extensions = ['jpg', 'mp4', 'mp3']; + const ext = extensions[Math.floor(Math.random() * extensions.length)]; + return `https://url.com/${Math.floor(1000 * Math.random())}.${ext}`; + } } } diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx index 963847d..52080ad 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx @@ -244,6 +244,78 @@ function TableRowComponent(props: { ); } + case 'MEDIA': { + // Polymorphic field — render with the kind detected at coercion time, + // falling back to a plain link when detection didn't resolve. + const url = value?.url; + if (url == null) { + return ; + } + if (value.mediaType === 'IMAGE') { + return ( +
+ + {label ?
{label}
: null} +
+ ); + } + if (value.mediaType === 'VIDEO') { + return ( +
+ + {label ?
{label}
: null} +
+ ); + } + if (value.mediaType === 'AUDIO') { + return ( +
+ {label ?
{label}
: null} + +
+ ); + } + return ( +
+ {label ?
{label}
: null} + + {url} + +
+ ); + } case 'DATETIME': { return (
@@ -348,6 +420,7 @@ function FieldComponent(props: { case 'STRING': case 'USER_ID': case 'VIDEO': + case 'MEDIA': case 'RELATED_ITEM': case 'URL': case 'POLICY_ID': @@ -411,6 +484,7 @@ function ContainerComponent(props: { return true; case 'AUDIO': case 'IMAGE': + case 'MEDIA': case 'RELATED_ITEM': case 'URL': case 'VIDEO': @@ -449,6 +523,7 @@ function ContainerComponent(props: { case 'URL': case 'POLICY_ID': case 'IP_ADDRESS': + case 'MEDIA': case 'VIDEO': { throw Error('Cannot call container component with scalar field'); } @@ -514,7 +589,8 @@ function ContainerComponent(props: { className={` ${ data.container!.valueScalarType === 'IMAGE' || data.container!.valueScalarType === 'VIDEO' || - data.container!.valueScalarType === 'AUDIO' + data.container!.valueScalarType === 'AUDIO' || + data.container!.valueScalarType === 'MEDIA' ? '' : 'flex-col' } flex overflow-x-scroll border-slate-200 rounded p-1.5 ${ diff --git a/client/src/webpages/dashboard/rules/info/RuleTestModal.tsx b/client/src/webpages/dashboard/rules/info/RuleTestModal.tsx index 88db331..8c8c374 100644 --- a/client/src/webpages/dashboard/rules/info/RuleTestModal.tsx +++ b/client/src/webpages/dashboard/rules/info/RuleTestModal.tsx @@ -130,6 +130,8 @@ export default function RuleTestModal(props: { return 'https://test.com/image.jpg'; case 'VIDEO': return 'https://test.com/video.mp4'; + case 'MEDIA': + return 'https://test.com/media.jpg'; case 'STRING': return 'Some text...'; case 'URL': diff --git a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx index 49583b1..1caf6c9 100644 --- a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx @@ -767,6 +767,7 @@ export function getStringFromContent( return content.toString(); case GQLFieldType.Image: case GQLFieldType.Video: + case GQLFieldType.Media: return typeof content === 'object' ? content.url : null; case GQLFieldType.Datetime: return parseDatetimeToReadableStringInCurrentTimeZone(content); diff --git a/client/src/webpages/dashboard/rules/types.ts b/client/src/webpages/dashboard/rules/types.ts index 3a8bea9..73771d5 100644 --- a/client/src/webpages/dashboard/rules/types.ts +++ b/client/src/webpages/dashboard/rules/types.ts @@ -151,6 +151,7 @@ export function isMediaType(it: GQLScalarType): boolean { return ( it === GQLScalarType.Audio || it === GQLScalarType.Video || - it === GQLScalarType.Image + it === GQLScalarType.Image || + it === GQLScalarType.Media ); } diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 63f3590..637936b 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -1344,6 +1344,7 @@ export const GQLFieldType = { Image: 'IMAGE', IpAddress: 'IP_ADDRESS', Map: 'MAP', + Media: 'MEDIA', Number: 'NUMBER', PolicyId: 'POLICY_ID', RelatedItem: 'RELATED_ITEM', @@ -4144,6 +4145,7 @@ export const GQLScalarType = { Id: 'ID', Image: 'IMAGE', IpAddress: 'IP_ADDRESS', + Media: 'MEDIA', Number: 'NUMBER', PolicyId: 'POLICY_ID', RelatedItem: 'RELATED_ITEM', @@ -4284,6 +4286,7 @@ export const GQLSignalInputType = { Id: 'ID', Image: 'IMAGE', IpAddress: 'IP_ADDRESS', + Media: 'MEDIA', Number: 'NUMBER', PolicyId: 'POLICY_ID', RelatedItem: 'RELATED_ITEM', diff --git a/server/graphql/schema.ts b/server/graphql/schema.ts index b8ee660..8c8676a 100644 --- a/server/graphql/schema.ts +++ b/server/graphql/schema.ts @@ -59,6 +59,7 @@ const typeDefs = /* GraphQL */ ` AUDIO IMAGE VIDEO + MEDIA DATETIME GEOHASH RELATED_ITEM @@ -77,6 +78,7 @@ const typeDefs = /* GraphQL */ ` AUDIO IMAGE VIDEO + MEDIA DATETIME GEOHASH RELATED_ITEM @@ -101,6 +103,7 @@ const typeDefs = /* GraphQL */ ` AUDIO IMAGE VIDEO + MEDIA DATETIME GEOHASH ARRAY diff --git a/server/package-lock.json b/server/package-lock.json index 25d9fa9..efd1540 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -25,7 +25,7 @@ "@opentelemetry/api": "^1.8.0", "@opentelemetry/semantic-conventions": "^1.22.0", "@roostorg/coop-integration-example": "^2.0.0", - "@roostorg/coop-types": "^2.2.0", + "@roostorg/coop-types": "^2.3.0", "@sendgrid/mail": "^8.1.6", "@stdlib/stats-binomial-test": "^0.0.7", "@total-typescript/ts-reset": "^0.3.7", @@ -3551,9 +3551,9 @@ } }, "node_modules/@roostorg/coop-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@roostorg/coop-types/-/coop-types-2.2.0.tgz", - "integrity": "sha512-qq7WmaGIPZo/bzJfhIme5yVU/SRtFeifEzq3NJi/PFjOEh2lgj30vUZVCeZHsi8sEjmj63sFeUyrM+kiHZKljg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@roostorg/coop-types/-/coop-types-2.3.0.tgz", + "integrity": "sha512-aOHomJIoqNUUoHxewxjOe6x2Y7ALcVgweUX7SxD18DwfylYK1KJZi4Q5nZB0SMm6q7V5H6btJfo4jRQMLxUT+g==", "license": "ISC", "dependencies": { "date-fns": "^2.29.3", diff --git a/server/package.json b/server/package.json index 8098e47..e7afdb1 100644 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "@opentelemetry/api": "^1.8.0", "@opentelemetry/semantic-conventions": "^1.22.0", "@roostorg/coop-integration-example": "^2.0.0", - "@roostorg/coop-types": "^2.2.0", + "@roostorg/coop-types": "^2.3.0", "@sendgrid/mail": "^8.1.6", "@stdlib/stats-binomial-test": "^0.0.7", "@total-typescript/ts-reset": "^0.3.7", @@ -55,8 +55,8 @@ "dataloader": "^2.1.0", "date-fns": "^2.30.0", "express": "^5.1.0", - "fast-check": "^4.6.0", "express-session": "^1.17.2", + "fast-check": "^4.6.0", "form-data": "^4.0.0", "form-data-encoder": "^4.0.2", "formdata-node": "^6.0.3", diff --git a/server/services/itemProcessingService/fieldTypeHandlers.test.ts b/server/services/itemProcessingService/fieldTypeHandlers.test.ts index 4342997..89d7ae8 100644 --- a/server/services/itemProcessingService/fieldTypeHandlers.test.ts +++ b/server/services/itemProcessingService/fieldTypeHandlers.test.ts @@ -9,7 +9,10 @@ import { import fc from 'fast-check'; import { FieldArbitrary } from '../../test/arbitraries/ContentType.js'; -import { fieldTypeHandlers } from './fieldTypeHandlers.js'; +import { + detectMediaKindFromUrl, + fieldTypeHandlers, +} from './fieldTypeHandlers.js'; describe('Content type schemas', () => { describe('fieldTypeHandlers', () => { @@ -94,4 +97,79 @@ describe('Content type schemas', () => { ).toBeInstanceOf(Error); }); }); + + describe('MEDIA coercion', () => { + const { coerce } = fieldTypeHandlers[ScalarTypes.MEDIA]; + + test.each([ + ['https://example.com/cat.jpg', ScalarTypes.IMAGE], + ['https://example.com/cat.JPG', ScalarTypes.IMAGE], + ['https://example.com/photo.jpeg', ScalarTypes.IMAGE], + ['https://example.com/anim.gif', ScalarTypes.IMAGE], + ['https://example.com/pic.webp', ScalarTypes.IMAGE], + ['https://example.com/clip.mp4', ScalarTypes.VIDEO], + ['https://example.com/clip.mov', ScalarTypes.VIDEO], + ['https://example.com/clip.webm', ScalarTypes.VIDEO], + ['https://example.com/song.mp3', ScalarTypes.AUDIO], + ['https://example.com/song.m4a', ScalarTypes.AUDIO], + ['https://example.com/song.wav', ScalarTypes.AUDIO], + ])('resolves %s to mediaType=%s', (url, expectedKind) => { + expect(coerce(url, [])).toEqual({ url, mediaType: expectedKind }); + }); + + test.each([ + // Ambiguous container — .ogg can be audio or video; stay unresolved. + 'https://example.com/song.ogg', + // No extension. + 'https://example.com/profile/123', + // Unknown extension. + 'https://example.com/file.xyz', + // Trailing dot. + 'https://example.com/file.', + ])('returns mediaType=null for unresolved URL %s', (url) => { + expect(coerce(url, [])).toEqual({ url, mediaType: null }); + }); + + test('treats empty string as missing (returns null)', () => { + expect(coerce('', [])).toBeNull(); + }); + + test.each([ + ['not a url', 'invalid string'], + // eslint-disable-next-line no-script-url -- testing that a javascript: URL is rejected + ['javascript:alert(1)', 'blocked scheme'], + ['ftp://example.com/x.mp3', 'unsupported scheme'], + ])('rejects %s (%s)', (url) => { + expect(coerce(url, [])).toBeInstanceOf(Error); + }); + + test.each([42, true, {}, []])('rejects non-string input %p', (value) => { + expect(coerce(value, [])).toBeInstanceOf(Error); + }); + + test('keeps the original URL casing in the returned object', () => { + expect(coerce('https://example.com/cat.PNG?v=2', [])).toEqual({ + url: 'https://example.com/cat.PNG?v=2', + mediaType: ScalarTypes.IMAGE, + }); + }); + }); + + describe('detectMediaKindFromUrl', () => { + test('returns null for unparseable URL', () => { + expect(detectMediaKindFromUrl('not a url')).toBeNull(); + }); + + test('uses the last dot in the pathname', () => { + expect( + detectMediaKindFromUrl('https://example.com/path.with.dots/file.mp4'), + ).toBe(ScalarTypes.VIDEO); + }); + + test('ignores extensions in the query string', () => { + expect( + detectMediaKindFromUrl('https://example.com/foo?x=y.mp4'), + ).toBeNull(); + }); + }); }); diff --git a/server/services/itemProcessingService/fieldTypeHandlers.ts b/server/services/itemProcessingService/fieldTypeHandlers.ts index fa1ed0d..47a2c89 100644 --- a/server/services/itemProcessingService/fieldTypeHandlers.ts +++ b/server/services/itemProcessingService/fieldTypeHandlers.ts @@ -7,6 +7,7 @@ import { type ContainerType, type ContainerTypeRuntimeType, type ItemIdentifier, + type MediaKind, type RelatedItem, type ScalarType, type ScalarTypeRuntimeType, @@ -183,6 +184,10 @@ export const fieldTypeHandlers: Handlers = { coerce: coerceMediaUrlInput, getValues: scalarGetValues, }, + [ScalarTypes.MEDIA]: { + coerce: coerceMediaInput, + getValues: scalarGetValues, + }, [ScalarTypes.DATETIME]: { getValues: scalarGetValues, coerce(v) { @@ -312,6 +317,69 @@ function coerceMediaUrlInput(value: unknown) { : err; } +// Extension → media kind. Lowercase, no leading dot. Conservative — only +// extensions that map unambiguously to one kind are listed, so e.g. .ogg +// (audio or video container) stays unresolved. +const MEDIA_EXTENSION_TO_KIND: Readonly> = { + // Image + jpg: ScalarTypes.IMAGE, + jpeg: ScalarTypes.IMAGE, + png: ScalarTypes.IMAGE, + gif: ScalarTypes.IMAGE, + webp: ScalarTypes.IMAGE, + bmp: ScalarTypes.IMAGE, + svg: ScalarTypes.IMAGE, + avif: ScalarTypes.IMAGE, + heic: ScalarTypes.IMAGE, + heif: ScalarTypes.IMAGE, + tif: ScalarTypes.IMAGE, + tiff: ScalarTypes.IMAGE, + // Video + mp4: ScalarTypes.VIDEO, + m4v: ScalarTypes.VIDEO, + mov: ScalarTypes.VIDEO, + webm: ScalarTypes.VIDEO, + mkv: ScalarTypes.VIDEO, + avi: ScalarTypes.VIDEO, + flv: ScalarTypes.VIDEO, + // Audio + mp3: ScalarTypes.AUDIO, + m4a: ScalarTypes.AUDIO, + wav: ScalarTypes.AUDIO, + aac: ScalarTypes.AUDIO, + flac: ScalarTypes.AUDIO, + opus: ScalarTypes.AUDIO, + wma: ScalarTypes.AUDIO, +}; + +/** + * Best-effort kind detection from a URL's pathname extension. Returns `null` + * when the URL is unparseable, has no extension, or has an extension we don't + * map. Consumers that need stronger guarantees should probe Content-Type. + */ +export function detectMediaKindFromUrl(url: string): MediaKind | null { + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + return null; + } + const dot = pathname.lastIndexOf('.'); + if (dot < 0 || dot === pathname.length - 1) return null; + const ext = pathname.slice(dot + 1).toLowerCase(); + return MEDIA_EXTENSION_TO_KIND[ext] ?? null; +} + +function coerceMediaInput(value: unknown) { + const err = new Error('This field, if given, must hold a valid URL.'); + + if (typeof value !== 'string') return err; + if (value === '') return null; + if (!isValidUrl(value)) return err; + + return { url: value, mediaType: detectMediaKindFromUrl(value) }; +} + function coerceIdLikeInput(value: unknown) { // NB: we don't currently have any restrictions on the string in an `ID` field; // in particular, it's allowed to be empty. But note that _item ids_ (like in diff --git a/server/test/arbitraries/ContentType.ts b/server/test/arbitraries/ContentType.ts index 44885a6..c01bed4 100644 --- a/server/test/arbitraries/ContentType.ts +++ b/server/test/arbitraries/ContentType.ts @@ -56,6 +56,16 @@ export const IdLikeArbitrary = fc.string(); export const MediaUrlArbitrary = fc.record({ url: fc.string() /* todo */ }); +export const MediaArbitrary = fc.record({ + url: fc.string(), + mediaType: fc.constantFrom( + ScalarTypes.AUDIO, + ScalarTypes.IMAGE, + ScalarTypes.VIDEO, + null, + ), +}); + export const RelatedItemArbitrary = fc.record({ id: fc.string(), typeId: fc.string(), @@ -89,6 +99,7 @@ export const ScalarValidValuesArbitraries = { [ScalarTypes.URL]: fc.webUrl({ validSchemes: ['http', 'https'] }), [ScalarTypes.USER_ID]: IdLikeArbitrary, [ScalarTypes.VIDEO]: MediaUrlArbitrary, + [ScalarTypes.MEDIA]: MediaArbitrary, [ScalarTypes.DATETIME]: DateStringArbitrary, [ScalarTypes.RELATED_ITEM]: RelatedItemArbitrary, [ScalarTypes.POLICY_ID]: IdLikeArbitrary, -- 2.51.2