Skip to content

Commit

Permalink
One unified TIDAL API module (#221)
Browse files Browse the repository at this point in the history
This merges Catalogue, Playlist, Search and User into one module instead of four.
  • Loading branch information
osmestad authored Nov 1, 2024
1 parent a2cd8d4 commit 8933172
Show file tree
Hide file tree
Showing 20 changed files with 20,211 additions and 220 deletions.
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default [
'node_modules/*',
'packages/*/node_modules/*',
'packages/*/dist/*',
'packages/**/*.generated.ts',
'coverage',
],
},
Expand Down
13 changes: 13 additions & 0 deletions packages/api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [0.1.0] - 2024-xx-xx

### Changed

- Initial release of joined API types and `fetch` wrapper
18 changes: 18 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# TIDAL API v2 Beta

A thin wrapper around the API domains described at: https://developer.tidal.com/apiref (which is again built on the JSON API spec: https://jsonapi.org/format/)

The module provides Typescript types and a `fetch` based function for getting data, using: https://openapi-ts.pages.dev/

## Usage

One function is exposed that can be used for creating a function that can then do network calls: `createAPIClient`. Also the API types are exposed and can be used directly.

### Example
See the `examples/` folder for some ways it can be used.

To run it do: `pnpm dev`

## Development

Run `pnpm generateTypes` to regenerate the types from the API specs.
197 changes: 197 additions & 0 deletions packages/api/examples/example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/* eslint-disable no-console */
import { credentialsProvider, init as initAuth } from '@tidal-music/auth';

import { createAPIClient } from '../dist';

/**
* Runs the example with a client ID and client secret (from https://developer.tidal.com).
*
* @param {string} clientId The client ID.
* @param {string} clientSecret The client secret.
* @param {string} searchTerm The search term.
*/
async function runExample(clientId, clientSecret, searchTerm) {
await initAuth({
clientId,
clientSecret,
credentialsStorageKey: 'clientCredentials',
});

const apiClient = createAPIClient(credentialsProvider);
const results = document.getElementById('results');
results.innerHTML = '';

/**
* Retrieves an album by its ID.
*
* @param {string} id The ID of the album.
*/
async function getAlbum(id) {
const { data, error } = await apiClient.GET('/albums/{id}', {
params: {
path: { id },
query: { countryCode: 'NO' },
},
});

if (error) {
error.errors.forEach(
err => (results.innerHTML += `<li>${err.detail}</li>`),
);
} else {
for (const [key, value] of Object.entries(data.data.attributes)) {
results.innerHTML += `<li><b>${key}:</b>${JSON.stringify(value)}</li>`;
}
}
}
// Example of an API request
await getAlbum('75413011');

/**
* Retrieves an album with the tracks and other relationships.
*
* @param {string} albumId The ID of the album.
*/
async function getAlbumWithTracks(albumId) {
const { data, error } = await apiClient.GET('/albums/{albumId}', {
params: {
path: { albumId },
query: { countryCode: 'NO', include: 'items,artists,providers' },
},
});

if (error) {
console.error(error);
} else {
console.log(data);
console.log(JSON.stringify(data));
}
}
// Example of another API request
await getAlbumWithTracks('75413011');

/**
* Retrieves an artist by its ID.
*
* @param {string} id The ID of the artist.
*/
async function getArtist(id) {
const { data, error } = await apiClient.GET('/artists/{id}', {
params: {
path: { id },
query: { countryCode: 'NO' },
},
});

if (error) {
console.error(error);
} else {
console.log(data.data.attributes.name);
}
}
// Example failing API request
await getArtist('1');

/**
* Retrieves a playlist by its ID.
*
* @param {string} id The ID of the playlist.
*/
async function getPlaylist(id) {
const { data, error } = await apiClient.GET('/playlists/{id}', {
params: {
path: { id },
query: { countryCode: 'NO' },
},
});

if (error) {
error.errors.forEach(
err => (results.innerHTML += `<li>${err.detail}</li>`),
);
} else {
for (const [key, value] of Object.entries(data.data.attributes)) {
results.innerHTML += `<li><b>${key}:</b>${JSON.stringify(value)}</li>`;
}
}
}
// Example of a playlist API request
await getPlaylist('bd878bbc-c0a1-4b81-9a2a-a16dc9926300');

/**
* Do a search for a term
*
* @param {string} query The search term.
*/
async function doSearch(query) {
const { data, error } = await apiClient.GET('/searchresults/{query}', {
params: {
path: { query },
query: { countryCode: 'NO', include: 'topHits' },
},
});

if (error) {
error.errors.forEach(
err => (results.innerHTML += `<li>${err.detail}</li>`),
);
} else {
data.data.relationships.topHits.data.forEach(hit => {
const item = data.included.find(i => i.id === hit.id);
const text = item.attributes.title || item.attributes.name;
results.innerHTML += `<li><b>${hit.type}:</b>${text}</li>`;
});
}
}
// Example of a search API request
await doSearch(searchTerm);

/**
* Retrieves User Public Profile by their ID.
*
* @param {string} id The ID of the user.
*/
async function getUserPublicProfile(id) {
const { data, error } = await apiClient.GET('/userPublicProfiles/{id}', {
params: {
path: { id },
query: { countryCode: 'NO' },
},
});

if (error) {
error.errors.forEach(
err => (results.innerHTML += `<li>${err.detail}</li>`),
);
} else {
for (const [key, value] of Object.entries(data.data)) {
results.innerHTML += `<li><b>${key}:</b>${JSON.stringify(value)}</li>`;
}
}
}
// Example of a user API request
await getUserPublicProfile('12345');
}

const authenticateHandler = async (event, form) => {
event.preventDefault();

if (!form) {
return;
}

const formData = new FormData(form);
const clientId = formData.get('clientId');
const clientSecret = formData.get('clientSecret');
const searchTerm = formData.get('searchTerm');

await runExample(clientId, clientSecret, searchTerm);
};

window.addEventListener('load', () => {
const form = document.getElementById('clientCredentialsForm');

form?.addEventListener('submit', event => {
authenticateHandler(event, form).catch(error => console.error(error));
});
});
30 changes: 30 additions & 0 deletions packages/api/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<html>
<head>
<style>
label {
margin-bottom: 5px;
}
</style>
</head>
<body>
<form id="clientCredentialsForm">
<label>
<span>Client ID</span>
<input type="text" name="clientId" />
</label>
<label>
<span>Client Secret</span>
<input type="text" name="clientSecret" />
</label>
<label>
<span>Search term</span>
<input type="search" name="searchTerm" />
</label>
<button type="submit">Authenticate and Search</button>
</form>

<ul id="results"></ul>

<script type="module" src="./examples/example.js"></script>
</body>
</html>
50 changes: 50 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@tidal-music/api",
"version": "0.1.0",
"type": "module",
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "ssh://[email protected]:tidal-music/tidal-sdk-web.git"
},
"license": "Apache-2.0",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
"default": "./dist/index.js",
"import": "./dist/index.js"
},
"scripts": {
"prepare": "vite build",
"build": "vite build",
"build:dev": "vite build -m development",
"clean": "rm -rf coverage dist .eslintcache",
"dev": "vite --debug --cors -c=./vite.config.ts",
"lint": "eslint . --cache --cache-strategy content",
"lint:ci": "eslint . --quiet",
"lint:fix": "pnpm run lint --fix",
"preview": "vite preview",
"generateTypes": "openapi-typescript",
"test": "vitest",
"test:coverage": "pnpm run test --coverage",
"test:ui": "pnpm run test:coverage --ui",
"typecheck": "tsc"
},
"dependencies": {
"@tidal-music/api": "workspace:*",
"openapi-fetch": "0.12.0"
},
"devDependencies": {
"@tidal-music/auth": "workspace:^",
"@tidal-music/common": "workspace:^",
"@vitest/coverage-v8": "2.0.4",
"@vitest/ui": "2.0.4",
"openapi-typescript": "7.4.0",
"typescript": "5.6.2",
"vite": "5.4.6",
"vite-plugin-dts": "4.2.1",
"vitest": "2.0.4"
}
}
17 changes: 17 additions & 0 deletions packages/api/redocly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apis:
catalogue:
root: https://developer.tidal.com/apiref/api-specifications/api-public-catalogue-jsonapi/tidal-catalog-v2-openapi-3.0.json
x-openapi-ts:
output: ./src/catalogueAPI.generated.ts
playlist:
root: https://developer.tidal.com/apiref/api-specifications/api-public-user-content/tidal-user-content-openapi-3.0.json
x-openapi-ts:
output: ./src/playlistAPI.generated.ts
search:
root: https://developer.tidal.com/apiref/api-specifications/api-public-search-jsonapi/tidal-search-v2-openapi-3.0.json
x-openapi-ts:
output: ./src/searchAPI.generated.ts
user:
root: https://developer.tidal.com/apiref/api-specifications/api-public-user-jsonapi/tidal-user-v2-openapi-3.0.json
x-openapi-ts:
output: ./src/userAPI.generated.ts
11 changes: 11 additions & 0 deletions packages/api/src/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createAPIClient } from './api';

describe('createAPIClient', () => {
it('creates a TIDAL API client', () => {
const client = createAPIClient({
bus: vi.fn(),
getCredentials: vi.fn(),
});
expect(client).toBeDefined();
});
});
33 changes: 33 additions & 0 deletions packages/api/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { CredentialsProvider } from '@tidal-music/common';
import createClient, { type Middleware } from 'openapi-fetch';

import type { paths as cataloguePaths } from './catalogueAPI.generated';
import type { paths as playlistPaths } from './playlistAPI.generated';
import type { paths as searchPaths } from './searchAPI.generated';
import type { paths as userPaths } from './userAPI.generated';

/**
* Create a Catalogue API client with the provided credentials.
*
* @param credentialsProvider The credentials provider, from Auth module.
*/
export function createAPIClient(credentialsProvider: CredentialsProvider) {
const authMiddleware: Middleware = {
async onRequest({ request }) {
const credentials = await credentialsProvider.getCredentials();

// Add Authorization header to every request
request.headers.set('Authorization', `Bearer ${credentials.token}`);
return request;
},
};

type AllPaths = cataloguePaths & playlistPaths & searchPaths & userPaths;

const apiClient = createClient<AllPaths>({
baseUrl: 'https://openapi.tidal.com/v2/',
});
apiClient.use(authMiddleware);

return apiClient;
}
Loading

0 comments on commit 8933172

Please sign in to comment.