diff --git a/src/composables/use-related.js b/src/composables/use-related.js index 73e126d77e..e41499dc03 100644 --- a/src/composables/use-related.js +++ b/src/composables/use-related.js @@ -1,14 +1,10 @@ -import AudioService from '~/data/audio-service' -import ImageService from '~/data/image-service' import { ref, useFetch } from '@nuxtjs/composition-api' -import { AUDIO, IMAGE } from '~/constants/media' - -const services = { [AUDIO]: AudioService, [IMAGE]: ImageService } +import { mediaServices } from '~/store/media' export default function useRelated({ mediaType, mediaId, - service = services[mediaType], + service = mediaServices[mediaType], }) { const media = ref([]) // fetch and fetchState are available as this.$fetch and this.$fetchState @@ -16,10 +12,10 @@ export default function useRelated({ // see https://composition-api.nuxtjs.org/lifecycle/usefetch/ // eslint-disable-next-line no-unused-vars const { fetch } = useFetch(async () => { - const response = await service.getRelatedMedia({ + const data = await service.getRelatedMedia({ id: mediaId.value, }) - media.value = response.data.results + media.value = data.results }) fetch() return { media } diff --git a/src/data/api-service.js b/src/data/api-service.js index 86989d57ea..b9795e52dc 100644 --- a/src/data/api-service.js +++ b/src/data/api-service.js @@ -1,8 +1,20 @@ import axios from 'axios' import { warn } from '~/utils/warn' +import { AUDIO, IMAGE } from '~/constants/media' const DEFAULT_REQUEST_TIMEOUT = 30000 +/** + * Returns a slug with trailing slash for a given resource name. + * For media types, converts the name into resource slug when necessary (i.e. pluralizes 'image'), + * for other resources uses the resource name as the slug. + * @param {string} resource + * @returns {string} + */ +const getResourceSlug = (resource) => { + const slug = { [AUDIO]: 'audio', [IMAGE]: 'images' }[resource] ?? resource + return `${slug}/` +} /** * @param {boolean} errorCondition * @param {string} message @@ -62,7 +74,7 @@ export const createApiService = (baseUrl = process.env.apiUrl) => { * @returns {Promise>} response The API response object */ query(resource, params) { - return client.get(resource, { params }) + return client.get(`${getResourceSlug(resource)}`, { params }) }, /** @@ -72,7 +84,7 @@ export const createApiService = (baseUrl = process.env.apiUrl) => { * @returns {Promise>} Response The API response object */ get(resource, slug) { - return client.get(`${resource}/${slug}/`) + return client.get(`${getResourceSlug(resource)}${slug}/`) }, /** @@ -82,7 +94,7 @@ export const createApiService = (baseUrl = process.env.apiUrl) => { * @returns {Promise>} Response The API response object */ post(resource, data) { - return client.post(resource, data) + return client.post(getResourceSlug(resource), data) }, /** @@ -94,7 +106,9 @@ export const createApiService = (baseUrl = process.env.apiUrl) => { * @returns {Promise>} Response The API response object */ update(resource, slug, data, headers) { - return client.put(`${resource}/${slug}`, data, { headers }) + return client.put(`${getResourceSlug(resource)}${slug}`, data, { + headers, + }) }, /** @@ -103,7 +117,7 @@ export const createApiService = (baseUrl = process.env.apiUrl) => { * @returns {Promise>} Response The API response object */ put(resource, params) { - return client.put(resource, params) + return client.put(getResourceSlug(resource), params) }, /** @@ -113,7 +127,7 @@ export const createApiService = (baseUrl = process.env.apiUrl) => { * @returns {Promise>} Response The API response object */ delete(resource, slug, headers) { - return client.delete(`${resource}/${slug}`, { headers }) + return client.delete(`${getResourceSlug(resource)}${slug}`, { headers }) }, } } diff --git a/src/data/audio-service.js b/src/data/audio-service.js deleted file mode 100644 index 76ec990f94..0000000000 --- a/src/data/audio-service.js +++ /dev/null @@ -1,52 +0,0 @@ -import ApiService from '~/data/api-service' -import BaseMediaService from '~/data/base-media-service' - -import { AUDIO } from '~/constants/media' - -const AudioService = { - ...BaseMediaService(AUDIO), - - /** - * Search for audios by keyword. - * @param {Object} params - * @return {Promise<{data: any}>} - */ - search(params) { - return ApiService.query('audio/', params) - }, - - /** - * Retrieve audio details by Id number. - * SSR-called - * @param {object} params - * @param {string} params.id - * @return {Promise<{data: any}>} - */ - getMediaDetail(params) { - if (!params.id) { - throw new Error( - '[RWV] AudioService.getMediaDetail() id parameter required to retrieve audio details.' - ) - } - - return ApiService.get('audio', params.id) - }, - - /** - * Retrieve related media - * @param {object} params - * @param {string} params.id - * @return {Promise<{data: any}>} - */ - getRelatedMedia(params) { - if (!params.id) { - throw new Error( - '[RWV] AudioService.getRelatedMedia() id parameter required to retrieve related audios.' - ) - } - - return ApiService.get('audio', `${params.id}/related`) - }, -} - -export default AudioService diff --git a/src/data/base-media-service.js b/src/data/base-media-service.js deleted file mode 100644 index 35ac3ad749..0000000000 --- a/src/data/base-media-service.js +++ /dev/null @@ -1,13 +0,0 @@ -import decodeMediaData from '~/utils/decode-media-data.js' - -const BaseMediaService = (mediaType) => ({ - transformResults(data) { - data.results = data.results.reduce((acc, item) => { - acc[item.id] = decodeMediaData(item, mediaType) - return acc - }, {}) - return data - }, -}) - -export default BaseMediaService diff --git a/src/data/image-service.js b/src/data/image-service.js deleted file mode 100644 index f33f4b2140..0000000000 --- a/src/data/image-service.js +++ /dev/null @@ -1,47 +0,0 @@ -import ApiService from './api-service' -import BaseMediaService from '~/data/base-media-service.js' - -import { IMAGE } from '~/constants/media' - -const ImageService = { - ...BaseMediaService(IMAGE), - - /** - * Search for images by keyword. - */ - search(params) { - return ApiService.query('images/', params) - }, - - /** - * Retrieve image details by Id number. - * SSR-called - */ - getMediaDetail(params) { - if (!params.id) { - throw new Error( - '[RWV] ImageService.getMediaDetail() id parameter required to retrieve image details.' - ) - } - - return ApiService.get('images', params.id) - }, - - /** - * Retrieve related media - * @param {object} params - * @param {string} params.id - * @return {Promise<{data: any}>} - */ - getRelatedMedia(params) { - if (!params.id) { - throw new Error( - '[RWV] ImageService.getRelatedImages() id parameter required to retrieve related images.' - ) - } - - return ApiService.get('images', `${params.id}/related`) - }, -} - -export default ImageService diff --git a/src/data/media-service.js b/src/data/media-service.js new file mode 100644 index 0000000000..4e21095572 --- /dev/null +++ b/src/data/media-service.js @@ -0,0 +1,83 @@ +import ApiService from '~/data/api-service' + +import decodeMediaData from '~/utils/decode-media-data' + +/** + * @template {import('../store/types').FrontendMediaType} [T=any] + */ +class MediaService { + /** + * @param {T} mediaType + */ + constructor(mediaType) { + /** @type {T} */ + this.mediaType = mediaType + } + + /** + * Decodes the text data to avoid encoding problems. + * Also, converts the results from an array of media objects into an object with + * media id as keys. + * @param {import('axios').AxiosResponse} data + * @returns {import('../store/types').MediaStoreResult} + */ + transformResults(data) { + return { + ...data, + results: data.results.reduce((acc, item) => { + acc[item.id] = decodeMediaData(item, this.mediaType) + return acc + }, /** @type {Record>} */ ({})), + } + } + + /** + * Search for media items by keyword. + * @param {import('../store/types').ApiQueryParams} params + * @return {Promise>>} + */ + async search(params) { + const res = await ApiService.query(this.mediaType, params) + return this.transformResults(res.data) + } + + /** + * Retrieve media details by its id. + * SSR-called + * @param {{ id: string }} params + * @return {Promise>>} + */ + async getMediaDetail(params) { + if (!params.id) { + throw new Error( + `MediaService.getMediaDetail() id parameter required to retrieve ${this.mediaType} details.` + ) + } + + const res = await ApiService.get(this.mediaType, params.id) + return decodeMediaData(res.data, this.mediaType) + } + + /** + * Retrieve related media + * @param {{ id: string }} params + * @return {Promise>>} + */ + async getRelatedMedia(params) { + if (!params.id) { + throw new Error( + `MediaService.getRelatedMedia() id parameter required to retrieve related media.` + ) + } + + const res = await ApiService.get(this.mediaType, `${params.id}/related`) + return { + ...res.data, + results: res.data.results.map((item) => + decodeMediaData(item, this.mediaType) + ), + } + } +} + +export default MediaService diff --git a/src/store/media.js b/src/store/media.js index a3d3f112df..0093801e77 100644 --- a/src/store/media.js +++ b/src/store/media.js @@ -1,5 +1,4 @@ import prepareSearchQueryParams from '~/utils/prepare-search-query-params' -import decodeMediaData from '~/utils/decode-media-data' import { FETCH_MEDIA, FETCH_SINGLE_MEDIA_TYPE, @@ -23,8 +22,7 @@ import { } from '~/constants/usage-data-analytics-types' import { AUDIO, IMAGE, ALL_MEDIA, supportedMediaTypes } from '~/constants/media' import { USAGE_DATA } from '~/constants/store-modules' -import AudioService from '~/data/audio-service' -import ImageService from '~/data/image-service' +import MediaService from '~/data/media-service' /** * @return {import('./types').MediaState} @@ -60,7 +58,12 @@ export const state = () => ({ image: {}, }) -export const createActions = (services) => ({ +export const mediaServices = { + [AUDIO]: new MediaService(AUDIO), + [IMAGE]: new MediaService(IMAGE), +} + +export const createActions = (services = mediaServices) => ({ /** * * @param {import('vuex').ActionContext} context @@ -127,13 +130,12 @@ export const createActions = (services) => ({ try { const mediaPage = typeof page === 'undefined' ? page : page[mediaType] - const res = await services[mediaType].search({ + const data = await services[mediaType].search({ ...queryParams, page: mediaPage, }) commit(FETCH_END_MEDIA, { mediaType }) - const data = services[mediaType].transformResults(res.data) const mediaCount = data.result_count commit(SET_MEDIA, { mediaType, @@ -176,8 +178,7 @@ export const createActions = (services) => ({ ) commit(SET_MEDIA_ITEM, { item: {}, mediaType }) try { - const res = await services[mediaType].getMediaDetail(params) - const { data } = res + const data = await services[mediaType].getMediaDetail(params) commit(SET_MEDIA_ITEM, { item: data, mediaType }) } catch (error) { if (error.response && error.response.status === 404) { @@ -223,6 +224,7 @@ export const createActions = (services) => ({ } }, }) +const actions = createActions() export const getters = { /** @@ -328,7 +330,7 @@ export const mutations = { }, [SET_MEDIA_ITEM](_state, params) { const { item, mediaType } = params - _state[mediaType] = decodeMediaData(item, mediaType) + _state[mediaType] = item }, [SET_MEDIA](_state, params) { const { @@ -368,9 +370,6 @@ export const mutations = { }, } -const mediaServices = { [AUDIO]: AudioService, [IMAGE]: ImageService } -const actions = createActions(mediaServices) - export default { state, getters, diff --git a/src/store/types.d.ts b/src/store/types.d.ts index 34fe3a1f8e..0b910a707d 100644 --- a/src/store/types.d.ts +++ b/src/store/types.d.ts @@ -5,11 +5,24 @@ export type SearchType = 'all' | MediaType /** * The search result object */ -export interface MediaResult { + +type FrontendMediaType = MediaDetail['frontendMediaType'] +export interface MediaResult< + T extends + | FrontendMediaType + | FrontendMediaType[] + | Record +> { result_count: number page_count: number page_size: number - results: T + results: T extends FrontendMediaType + ? DetailFromMediaType + : T extends Array + ? DetailFromMediaType

[] + : T extends Record + ? Record> + : never } export type Query = { @@ -41,57 +54,50 @@ export type ApiQueryParams = { mature?: string } -/** - * Audio Properties returned by the API - */ -export type AudioDetail = { +export interface Tag { + name: string + provider: [string] +} + +export interface BaseMediaDetail { id: string foreign_landing_url: string creator?: string creator_url?: string url: string + title?: string license: string license_version: string license_url: string provider: string source?: string - tags?: any + tags?: Tag[] attribution: string + detail_url: string + related_url: string + thumbnail?: string + frontendMediaType: FrontendMediaType +} + +export interface AudioDetail extends BaseMediaDetail<'audio'> { audio_set?: any genres?: any duration?: number bit_rate?: number sample_rate?: number alt_files?: any - detail_url: string - related_url: string filetype?: string - frontendMediaType?: 'audio' } -/** - * Image Properties returned by the API - */ -export type ImageDetail = { - id: string - title?: string - creator?: string - creator_url?: string - tags?: { name: string; provider: [string] }[] - url: string - thumbnail?: string - provider: string - source?: string - license: string - license_version: string - license_url: string - foreign_landing_url: string - detail_url: string - related_url: string +export interface ImageDetail extends BaseMediaDetail<'image'> { fields_matched?: string[] - frontendMediaType?: 'image' } +export type MediaDetail = ImageDetail | AudioDetail + +export type DetailFromMediaType = + T extends 'image' ? ImageDetail : T extends 'audio' ? AudioDetail : never + export interface FilterItem { code: string name: string @@ -132,24 +138,20 @@ export interface ActiveMediaState { status: 'ejected' | 'playing' | 'paused' // 'ejected' means player is closed } -export interface MediaStoreResult { - count: number - page?: number - pageCount: number - items: { [key: SupportedMediaType]: AudioDetail | ImageDetail } -} +export interface MediaStoreResult + extends MediaResult> {} export interface MediaState { results: { - audio: MediaStoreResult - image: MediaStoreResult + audio: MediaStoreResult<'audio'> + image: MediaStoreResult<'image'> } fetchState: { audio: FetchState image: FetchState } - audio: Object | AudioDetail - image: Object | ImageDetail + audio: AudioDetail + image: ImageDetail } export interface MediaFetchState { diff --git a/src/utils/decode-data.js b/src/utils/decode-data.js index 66f9eb2625..5f36e45354 100644 --- a/src/utils/decode-data.js +++ b/src/utils/decode-data.js @@ -1,21 +1,20 @@ /** * decodes some edge cases of Unicode characters with an extra \ * See test cases for some examples - * @param {string} data + * @param {string} [data] */ export default function decodeData(data) { if (data) { try { const regexASCII = /\\x([\d\w]{2})/gi - const ascii = data.replace(regexASCII, (match, grp) => + const ascii = data.replace(regexASCII, (_, grp) => String.fromCharCode(parseInt(grp, 16)) ) const regexUni = /\\u([\d\w]{4})/gi - const uni = ascii.replace(regexUni, (match, grp) => + const uni = ascii.replace(regexUni, (_, grp) => String.fromCharCode(parseInt(grp, 16)) ) - const res = decodeURI(uni) - return res + return decodeURI(uni) } catch (e) { return data } diff --git a/src/utils/decode-media-data.js b/src/utils/decode-media-data.js index 556152aeda..70f7c16255 100644 --- a/src/utils/decode-media-data.js +++ b/src/utils/decode-media-data.js @@ -1,9 +1,16 @@ import decodeData from '~/utils/decode-data' import { IMAGE } from '~/constants/media' +/** + * @template {import('../store/types').MediaDetail} T + * @param {T} media + * @param {import('../store/types').SupportedMediaType} mediaType + * @return {T} + */ export default function decodeMediaData(media, mediaType = IMAGE) { return { ...media, + frontendMediaType: mediaType, creator: decodeData(media.creator), title: decodeData(media.title) ? decodeData(media.title) diff --git a/test/unit/specs/components/related-audios.spec.js b/test/unit/specs/components/related-audios.spec.js index c5134ca953..1144b741b0 100644 --- a/test/unit/specs/components/related-audios.spec.js +++ b/test/unit/specs/components/related-audios.spec.js @@ -6,9 +6,7 @@ import VueI18n from 'vue-i18n' const audioResults = [{ id: 'audio1' }, { id: 'audio2' }] const serviceMock = { - getRelatedMedia: jest.fn(() => - Promise.resolve({ data: { results: audioResults } }) - ), + getRelatedMedia: jest.fn(() => Promise.resolve({ results: audioResults })), } const localVue = createLocalVue() diff --git a/test/unit/specs/components/related-images.spec.js b/test/unit/specs/components/related-images.spec.js index da7936e172..5e6d29f189 100644 --- a/test/unit/specs/components/related-images.spec.js +++ b/test/unit/specs/components/related-images.spec.js @@ -6,12 +6,10 @@ import VRelatedImages from '~/components/VImageDetails/VRelatedImages' const serviceMock = jest.fn(() => Promise.resolve({ - data: { - results: [ - { id: 'img1', url: 'https://wp.org/img1.jpg' }, - { id: 'img2', url: 'https://wp.org/img2.jpg' }, - ], - }, + results: [ + { id: 'img1', url: 'https://wp.org/img1.jpg' }, + { id: 'img2', url: 'https://wp.org/img2.jpg' }, + ], }) ) const failedMock = jest.fn(() => Promise.reject('No result')) diff --git a/test/unit/specs/store/media-store.spec.js b/test/unit/specs/store/media-store.spec.js index 7b316038d9..c68908cc24 100644 --- a/test/unit/specs/store/media-store.spec.js +++ b/test/unit/specs/store/media-store.spec.js @@ -182,9 +182,8 @@ describe('Search Store', () => { }) describe('actions', () => { - const searchData = { results: ['foo'], result_count: 22, page_count: 2 } const detailData = { [AUDIO]: 'audioDetails', [IMAGE]: 'imageDetails' } - const transformedResults = { + const searchResults = { results: { foo: { id: 'foo' }, bar: { id: 'bar' }, zeta: { id: 'zeta' } }, result_count: 22, page_count: 2, @@ -195,18 +194,12 @@ describe('Search Store', () => { beforeEach(() => { services = { [AUDIO]: { - search: jest.fn(() => Promise.resolve({ data: searchData })), - getMediaDetail: jest.fn(() => - Promise.resolve({ data: detailData[AUDIO] }) - ), - transformResults: jest.fn(() => transformedResults), + search: jest.fn(() => Promise.resolve(searchResults)), + getMediaDetail: jest.fn(() => Promise.resolve(detailData[AUDIO])), }, [IMAGE]: { - search: jest.fn(() => Promise.resolve({ data: searchData })), - getMediaDetail: jest.fn(() => - Promise.resolve({ data: detailData[IMAGE] }) - ), - transformResults: jest.fn(() => transformedResults), + search: jest.fn(() => Promise.resolve(searchResults)), + getMediaDetail: jest.fn(() => Promise.resolve(detailData[IMAGE])), }, } state = { @@ -243,7 +236,7 @@ describe('Search Store', () => { }) it.each(supportedMediaTypes)( - 'FETCH_SINGLE_MEDIA_TYPE on success', + 'FETCH_SINGLE_MEDIA_TYPE (%s) on success', async (mediaType) => { const params = { q: 'foo', @@ -263,11 +256,11 @@ describe('Search Store', () => { // Page parameter is converted from an object into a number params.page = 1 expect(context.commit).toHaveBeenCalledWith(SET_MEDIA, { - media: transformedResults.results, - mediaCount: searchData.result_count, + media: searchResults.results, + mediaCount: searchResults.result_count, shouldPersistMedia: params.shouldPersistMedia, page: params.page, - pageCount: searchData.page_count, + pageCount: searchResults.page_count, mediaType, }) delete params.mediaType @@ -369,7 +362,7 @@ describe('Search Store', () => { }) it.each(supportedMediaTypes)( - 'FETCH_MEDIA_ITEM on success', + 'FETCH_MEDIA_ITEM (%s) on success', async (mediaType) => { const params = { id: 'foo', mediaType } const action = createActions(services)[FETCH_MEDIA_ITEM] diff --git a/test/unit/specs/utils/decode-image-data.spec.js b/test/unit/specs/utils/decode-image-data.spec.js index f3f7ee7d55..945ed5d8f3 100644 --- a/test/unit/specs/utils/decode-image-data.spec.js +++ b/test/unit/specs/utils/decode-image-data.spec.js @@ -1,4 +1,5 @@ import decodeMediaData from '~/utils/decode-media-data' +import { IMAGE } from '~/constants/media' describe('decodeImageData', () => { it('returns empty string for empty string', () => { @@ -12,6 +13,7 @@ describe('decodeImageData', () => { title: 'Sé', creator: 'Sã', tags: [{ name: 'maß' }], + frontendMediaType: IMAGE, } expect(decodeMediaData(data)).toEqual(expected) diff --git a/tsconfig.json b/tsconfig.json index 4ae7c93284..f58c3d64e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -44,6 +44,8 @@ "src/data/api-service.js", "src/data/usage-data-service.js", "src/store/user.js", + "src/utils/decode-data.js", + "src/utils/decode-media-data.js", "src/utils/deep-freeze.js", "src/utils/key-codes.js", "src/utils/local.js",