├── .gitignore ├── .prettierrc.js ├── src ├── utils.ts ├── index.ts └── spotifyClient.ts ├── readme.md ├── package.json ├── LICENSE ├── .eslintrc.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 150, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const base64Encode = (s: string) => Buffer.from(s).toString('base64'); 2 | 3 | export const trace = (v: T) => { 4 | console.log(v); 5 | return v; 6 | }; 7 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # spotify-fp-ts-example 2 | 3 | A `fp-ts` rewrite of [DrBoolean spotify-fp-example](https://github.com/DrBoolean/spotify-fp-example) covered in his [egghead.io course](https://egghead.io/lessons/javascript-real-world-example-pt1). 4 | 5 | ## Install & Setup 6 | 7 | - `git clone ` 8 | - `cd spotify-fp-ts-example` 9 | - `npm install` 10 | 11 | ### Creating Spotify App 12 | Because spotlight now requires an API token to make requests against it's "public" API, you must [create an app](https://developer.spotify.com/documentation/web-api/quick-start/). 13 | 14 | Once you have an app, create a `.env` file in the project root with the following: 15 | 16 | ```bash 17 | SPOTIFY_CLIENT_ID=YOUR_CLIENT_ID 18 | SPOTIFY_CLIENT_SECRET=YOUR_SECRET 19 | ``` 20 | 21 | ## Running 22 | 23 | `npm start redman "big l" "the farcyde"` 24 | 25 | > Artists related to Redman & Big L & The Pharcyde: 26 | Jeru The Damaja 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spotify-fp-ts-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prestart": "npm run build", 8 | "start": "node ./dist/index.js", 9 | "build": "tsc --build", 10 | "clean": "tsc --build --clean", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@types/node": "^14.14.37", 17 | "axios": "^0.21.1", 18 | "dotenv": "^8.2.0", 19 | "fp-ts": "^2.9.5", 20 | "io-ts": "^2.2.16" 21 | }, 22 | "devDependencies": { 23 | "@typescript-eslint/eslint-plugin": "^4.20.0", 24 | "@typescript-eslint/parser": "^4.20.0", 25 | "eslint": "^7.23.0", 26 | "eslint-config-prettier": "^8.1.0", 27 | "eslint-plugin-prettier": "^3.3.1", 28 | "prettier": "^2.2.1", 29 | "typescript": "^4.2.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Charlie Jonas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: 'module', // Allows for the use of imports 6 | ecmaFeatures: { 7 | jsx: true, // Allows for the parsing of JSX 8 | }, 9 | }, 10 | settings: { 11 | react: { 12 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 13 | }, 14 | }, 15 | extends: [ 16 | //"plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react 17 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 18 | 'prettier', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 19 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 20 | ], 21 | rules: { 22 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 23 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 24 | 'prettier/prettier': 1, 25 | '@typescript-eslint/explicit-module-boundary-types': 0, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as A from 'fp-ts/Array'; 2 | import { eqString } from 'fp-ts/Eq'; 3 | import { flow, pipe } from 'fp-ts/function'; 4 | import { fold, Semigroup } from 'fp-ts/lib/Semigroup'; 5 | import * as T from 'fp-ts/Task'; 6 | import * as TE from 'fp-ts/TaskEither'; 7 | import { spotifyClient } from './spotifyClient'; 8 | import * as dotenv from 'dotenv'; 9 | dotenv.config(); 10 | 11 | const semigroupIntersection: Semigroup = { 12 | concat: (x, y) => A.intersection(eqString)(x)(y), 13 | }; 14 | 15 | const semigroupAll: Semigroup = { 16 | concat: (x, y) => [...x, ...y], 17 | }; 18 | 19 | const all = fold(semigroupAll); 20 | 21 | const intersect = fold(semigroupIntersection); 22 | 23 | const artistIntersection = flow((relatedLists: readonly string[][]) => intersect(all([], relatedLists), relatedLists)); 24 | 25 | const related = (client: ReturnType) => (artist: string) => 26 | pipe( 27 | client, 28 | TE.chain((client) => 29 | pipe( 30 | client.searchArtist(artist), 31 | TE.map((sr) => pipe(sr.artists.items, A.head)), 32 | TE.chain(TE.fromOption(() => new Error(`No matching artist found for: ${artist}`))), 33 | TE.chain((a) => 34 | pipe( 35 | client.relatedArtist(a.id), 36 | TE.map((related) => ({ 37 | artist: a, 38 | related: related.artists.map((artist) => artist.name), 39 | })), 40 | ), 41 | ), 42 | ), 43 | ), 44 | ); 45 | 46 | const main = (artists: string[]) => 47 | pipe( 48 | { clientId: process.env.SPOTIFY_CLIENT_ID as string, secret: process.env.SPOTIFY_CLIENT_SECRET as string }, 49 | spotifyClient, 50 | related, 51 | (getRelated) => pipe(artists, A.traverse(TE.taskEither)(getRelated)), 52 | TE.map((a) => ({ 53 | artists: a.map((i) => i.artist.name), 54 | related: artistIntersection(a.map((i) => i.related)), 55 | })), 56 | TE.map((a) => `Artists related to ${a.artists.join(' & ')}: \n${a.related.join('\n')}`), 57 | TE.fold( 58 | (e) => T.of(console.error(e)), 59 | (a) => T.of(console.log(a)), 60 | ), 61 | ); 62 | 63 | const getNames = pipe(process.argv, (args) => args.slice(2)); 64 | 65 | pipe(getNames, main)().then(); 66 | -------------------------------------------------------------------------------- /src/spotifyClient.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import * as E from 'fp-ts/Either'; 3 | import { flow, identity, pipe } from 'fp-ts/function'; 4 | import * as TE from 'fp-ts/TaskEither'; 5 | import * as iot from 'io-ts'; 6 | import { base64Encode, trace } from './utils'; 7 | 8 | const makeReq = TE.bimap( 9 | (e: unknown) => (e instanceof Error ? e : new Error(String(e))), 10 | (v: AxiosResponse): unknown => v.data, 11 | ); 12 | 13 | export const httpGet = flow(TE.tryCatchK(axios.get, identity), makeReq); 14 | 15 | export const httpPost = flow(TE.tryCatchK(axios.post, identity), makeReq); 16 | 17 | const validateJson = (decoder: iot.TypeC) => 18 | flow( 19 | (json: unknown) => json, 20 | decoder.decode, 21 | (v) => E.either.map(v, (artist) => artist), 22 | E.mapLeft((errors) => new Error(errors.map((error) => error.context.map(({ key }) => key).join('.')).join('\n'))), 23 | ); 24 | 25 | const tokenCodec = iot.type({ 26 | access_token: iot.string, 27 | token_type: iot.string, 28 | expires_in: iot.number, 29 | }); 30 | 31 | const getAuth = flow( 32 | (creds: { clientId: string; secret: string }) => creds, 33 | (c) => `${c.clientId}:${c.secret}`, 34 | base64Encode, 35 | (token: string) => 36 | httpPost('https://accounts.spotify.com/api/token', 'grant_type=client_credentials', { 37 | headers: { Authorization: `Basic ${token}`, 'content-type': 'application/x-www-form-urlencoded' }, 38 | }), 39 | TE.chain(flow(validateJson(tokenCodec), TE.fromEither)), 40 | ); 41 | 42 | const artistCodec = iot.type({ 43 | name: iot.string, 44 | id: iot.string, 45 | }); 46 | 47 | const artistsCodec = iot.type({ 48 | items: iot.array(artistCodec), 49 | }); 50 | 51 | const artistsResponseCodec = iot.type({ 52 | artists: artistsCodec, 53 | }); 54 | 55 | const relatedResponseCodec = iot.type({ 56 | artists: iot.array(artistCodec), 57 | }); 58 | 59 | export const spotifyClient = (creds: { clientId: string; secret: string }) => 60 | pipe( 61 | getAuth(creds), 62 | TE.map((authResult) => (uri: string, decoder: iot.TypeC) => 63 | pipe(httpGet(uri, { headers: { Authorization: `Bearer ${authResult.access_token}` } }), TE.chain(flow(validateJson(decoder), TE.fromEither))), 64 | ), 65 | TE.map((client) => ({ 66 | searchArtist: (name: string) => client(`https://api.spotify.com/v1/search?q=${name}&type=artist`, artistsResponseCodec), 67 | relatedArtist: (id: string) => client(`https://api.spotify.com/v1/artists/${id}/related-artists`, relatedResponseCodec), 68 | })), 69 | ); 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | --------------------------------------------------------------------------------