From d3a901e826aee5c8c94dfe366b2526fdc49bb1db Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Fri, 13 Nov 2020 16:50:27 +0100 Subject: [PATCH 1/8] Add back-end support and tests for integration objects. --- client/src/api.ts | 57 +++++++++- server/app.ts | 48 +++++--- server/endpoints/CreateIntegration.ts | 43 +++++++ server/endpoints/DeleteIntegration.ts | 49 ++++++++ server/endpoints/IntegrationDetails.ts | 34 ++++++ server/endpoints/ModifyIntegration.ts | 52 +++++++++ .../20201113155620_add_integrations.ts | 22 ++++ .../test/integration/flows/IntegrationFlow.js | 105 ++++++++++++++++++ server/test/integration/flows/helpers.js | 75 +++++++++++++ 9 files changed, 470 insertions(+), 15 deletions(-) create mode 100644 server/endpoints/CreateIntegration.ts create mode 100644 server/endpoints/DeleteIntegration.ts create mode 100644 server/endpoints/IntegrationDetails.ts create mode 100644 server/endpoints/ModifyIntegration.ts create mode 100644 server/migrations/20201113155620_add_integrations.ts create mode 100644 server/test/integration/flows/IntegrationFlow.js diff --git a/client/src/api.ts b/client/src/api.ts index d1cb334..63c3754 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -356,4 +356,59 @@ export function checkRegisterUserRequest(req: any): boolean { // Note: Login is handled by Passport.js, so it is not explicitly written here. export const LoginEndpoint = "/login"; -export const LogoutEndpoint = "/logout"; \ No newline at end of file +export const LogoutEndpoint = "/logout"; + +export enum IntegrationType { + spotify = "spotify", +} + +// Create a new integration (POST). +export const CreateIntegrationEndpoint = '/integration'; +export interface CreateIntegrationRequest { + name: string, + type: IntegrationType, + details: any, +} +export interface CreateIntegrationResponse { + id: number; +} +export function checkCreateIntegrationRequest(req: any): boolean { + return "body" in req && + "name" in req.body && + "type" in req.body && + "details" in req.body && + (req.body.type in IntegrationType); +} + +// Modify an existing integration (PUT). +export const ModifyIntegrationEndpoint = '/integration/:id'; +export interface ModifyIntegrationRequest { + name?: string, + type?: IntegrationType, + details?: any, +} +export interface ModifyIntegrationResponse { } +export function checkModifyIntegrationRequest(req: any): boolean { + if("type" in req.body && !(req.body.type in IntegrationType)) return false; + return true; +} + +// Get integration details (GET). +export const IntegrationDetailsEndpoint = '/integration/:id'; +export interface IntegrationDetailsRequest { } +export interface IntegrationDetailsResponse { + name: string, + type: IntegrationType, + details: any, +} +export function checkIntegrationDetailsRequest(req: any): boolean { + return true; +} + +// Delete integration (DELETE). +export const DeleteIntegrationEndpoint = '/integration/:id'; +export interface DeleteIntegrationRequest { } +export interface DeleteIntegrationResponse { } +export function checkDeleteIntegrationRequest(req: any): boolean { + return true; +} \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index 878a4ab..df82b1d 100644 --- a/server/app.ts +++ b/server/app.ts @@ -2,22 +2,33 @@ const bodyParser = require('body-parser'); import * as api from '../client/src/api'; import Knex from 'knex'; -import { CreateSongEndpointHandler } from './endpoints/CreateSong'; -import { CreateArtistEndpointHandler } from './endpoints/CreateArtist'; import { QueryEndpointHandler } from './endpoints/Query'; + +import { CreateArtistEndpointHandler } from './endpoints/CreateArtist'; import { ArtistDetailsEndpointHandler } from './endpoints/ArtistDetails' -import { SongDetailsEndpointHandler } from './endpoints/SongDetails'; import { ModifyArtistEndpointHandler } from './endpoints/ModifyArtist'; + import { ModifySongEndpointHandler } from './endpoints/ModifySong'; -import { CreateTagEndpointHandler } from './endpoints/CreateTag'; -import { ModifyTagEndpointHandler } from './endpoints/ModifyTag'; -import { TagDetailsEndpointHandler } from './endpoints/TagDetails'; +import { SongDetailsEndpointHandler } from './endpoints/SongDetails'; +import { CreateSongEndpointHandler } from './endpoints/CreateSong'; + import { CreateAlbumEndpointHandler } from './endpoints/CreateAlbum'; import { ModifyAlbumEndpointHandler } from './endpoints/ModifyAlbum'; import { AlbumDetailsEndpointHandler } from './endpoints/AlbumDetails'; + +import { CreateTagEndpointHandler } from './endpoints/CreateTag'; +import { ModifyTagEndpointHandler } from './endpoints/ModifyTag'; import { DeleteTagEndpointHandler } from './endpoints/DeleteTag'; import { MergeTagEndpointHandler } from './endpoints/MergeTag'; +import { TagDetailsEndpointHandler } from './endpoints/TagDetails'; + import { RegisterUserEndpointHandler } from './endpoints/RegisterUser'; + +import { CreateIntegrationEndpointHandler } from './endpoints/CreateIntegration'; +import { ModifyIntegrationEndpointHandler } from './endpoints/ModifyIntegration'; +import { DeleteIntegrationEndpointHandler } from './endpoints/DeleteIntegration'; +import { IntegrationDetailsEndpointHandler } from './endpoints/IntegrationDetails'; + import * as endpointTypes from './endpoints/types'; import { sha512 } from 'js-sha512'; @@ -108,26 +119,35 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { // Set up REST API endpoints app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(CreateSongEndpointHandler)); + app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(ModifySongEndpointHandler)); + app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(SongDetailsEndpointHandler)); + app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(QueryEndpointHandler)); + app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(CreateArtistEndpointHandler)); app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(ModifyArtistEndpointHandler)); - app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(ModifySongEndpointHandler)); - app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(SongDetailsEndpointHandler)); app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(ArtistDetailsEndpointHandler)); - app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(CreateTagEndpointHandler)); - app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(ModifyTagEndpointHandler)); - app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(TagDetailsEndpointHandler)); + app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(CreateAlbumEndpointHandler)); app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(ModifyAlbumEndpointHandler)); app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(AlbumDetailsEndpointHandler)); + + app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(CreateTagEndpointHandler)); + app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(ModifyTagEndpointHandler)); + app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(TagDetailsEndpointHandler)); app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTagEndpointHandler)); app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTagEndpointHandler)); - app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUserEndpointHandler)); - app.post(apiBaseUrl + '/login', passport.authenticate('local'), (req: any, res: any) => { + app.post(apiBaseUrl + api.CreateIntegrationEndpoint, checkLogin(), _invoke(CreateIntegrationEndpointHandler)); + app.put(apiBaseUrl + api.ModifyIntegrationEndpoint, checkLogin(), _invoke(ModifyIntegrationEndpointHandler)); + app.get(apiBaseUrl + api.IntegrationDetailsEndpoint, checkLogin(), _invoke(IntegrationDetailsEndpointHandler)); + app.delete(apiBaseUrl + api.DeleteIntegrationEndpoint, checkLogin(), _invoke(DeleteIntegrationEndpointHandler)); + + app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUserEndpointHandler)); + app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => { res.status(200).send({ userId: req.user.id }); }); - app.post(apiBaseUrl + '/logout', function (req: any, res: any) { + app.post(apiBaseUrl + api.LogoutEndpoint, function (req: any, res: any) { req.logout(); res.status(200).send(); }); diff --git a/server/endpoints/CreateIntegration.ts b/server/endpoints/CreateIntegration.ts new file mode 100644 index 0000000..6f6eef3 --- /dev/null +++ b/server/endpoints/CreateIntegration.ts @@ -0,0 +1,43 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; + +export const CreateIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid CreateIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Create Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Create the new integration. + var integration: any = { + name: reqObject.name, + user: userId, + type: reqObject.type, + details: JSON.stringify(reqObject.details), + } + const integrationId = (await trx('integrations') + .insert(integration) + .returning('id') // Needed for Postgres + )[0]; + + // Respond to the request. + const responseObject: api.CreateIntegrationResponse = { + id: integrationId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/DeleteIntegration.ts b/server/endpoints/DeleteIntegration.ts new file mode 100644 index 0000000..b0eff9b --- /dev/null +++ b/server/endpoints/DeleteIntegration.ts @@ -0,0 +1,49 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; + +export const DeleteIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkDeleteIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Delete Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving the integration itself. + const integrationId = await trx.select('id') + .from('integrations') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Check that we found all objects we need. + if (!integrationId) { + const e: EndpointError = { + internalMessage: 'Integration does not exist for DeleteIntegration request: ' + JSON.stringify(req.body), + httpStatus: 404 + }; + throw e; + } + + // Delete the integration. + await trx('integrations') + .where({ 'user': userId, 'id': integrationId }) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/IntegrationDetails.ts b/server/endpoints/IntegrationDetails.ts new file mode 100644 index 0000000..709ff25 --- /dev/null +++ b/server/endpoints/IntegrationDetails.ts @@ -0,0 +1,34 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; + +export const IntegrationDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkIntegrationDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid IntegrationDetails request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const integration = (await knex.select(['id', 'name', 'type', 'details']) + .from('integrations') + .where({ 'user': userId, 'id': req.params.id }))[0]; + + if (integration) { + const response: api.IntegrationDetailsResponse = { + name: integration.name, + type: integration.type, + details: JSON.parse(integration.details), + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} \ No newline at end of file diff --git a/server/endpoints/ModifyIntegration.ts b/server/endpoints/ModifyIntegration.ts new file mode 100644 index 0000000..0532ece --- /dev/null +++ b/server/endpoints/ModifyIntegration.ts @@ -0,0 +1,52 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; + +export const ModifyIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkModifyIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid ModifyIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.ModifyIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Modify Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving the integration. + const integrationId = await trx.select('id') + .from('integrations') + .where({ 'user': userId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Check that we found all objects we need. + if (!integrationId) { + const e: EndpointError = { + internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body), + httpStatus: 404 + }; + throw e; + } + + // Modify the integration. + var update: any = {}; + if ("name" in reqObject) { update["name"] = reqObject.name; } + if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); } + if ("type" in reqObject) { update["type"] = reqObject.type; } + await trx('integrations') + .where({ 'user': userId, 'id': req.params.id }) + .update(update) + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/migrations/20201113155620_add_integrations.ts b/server/migrations/20201113155620_add_integrations.ts new file mode 100644 index 0000000..c560218 --- /dev/null +++ b/server/migrations/20201113155620_add_integrations.ts @@ -0,0 +1,22 @@ +import * as Knex from "knex"; + + +export async function up(knex: Knex): Promise { + // Integrations table. + await knex.schema.createTable( + 'integrations', + (table: any) => { + table.increments('id'); + table.integer('user').unsigned().notNullable().defaultTo(1); + table.string('name').notNullable(); // Uniquely identifies this integration configuration for the user. + table.string('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify) + table.json('details'); // Stores anything that might be needed for the integration to work. + } + ) +} + + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable('integrations'); +} + diff --git a/server/test/integration/flows/IntegrationFlow.js b/server/test/integration/flows/IntegrationFlow.js new file mode 100644 index 0000000..366ab32 --- /dev/null +++ b/server/test/integration/flows/IntegrationFlow.js @@ -0,0 +1,105 @@ +const chai = require('chai'); +const chaiHttp = require('chai-http'); +const express = require('express'); +import { SetupApp } from '../../../app'; +import * as helpers from './helpers'; +import { sha512 } from 'js-sha512'; +import { IntegrationType } from '../../../../client/src/api'; + +async function init() { + chai.use(chaiHttp); + const app = express(); + const knex = await helpers.initTestDB(); + + // Add test users. + await knex.insert({ email: "test1@test.com", passwordHash: sha512('pass1') }).into('users'); + await knex.insert({ email: "test2@test.com", passwordHash: sha512('pass2') }).into('users'); + + SetupApp(app, knex, ''); + + // Login as a test user. + var agent = chai.request.agent(app); + await agent + .post('/login?username=' + encodeURIComponent("test1@test.com") + '&password=' + encodeURIComponent('pass1')) + .send({}); + return agent; +} + +describe('POST /integration with missing or wrong data', () => { + it('should fail', async done => { + let agent = await init(); + let req = agent.keepOpen(); + try { + await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify }, 400); + await helpers.createIntegration(req, { details: {}, type: IntegrationType.spotify }, 400); + await helpers.createIntegration(req, { name: "A", details: {} }, 400); + await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {} }, 400); + } finally { + req.close(); + agent.close(); + done(); + } + }); +}); + +describe('POST /integration with a correct request', () => { + it('should succeed', async done => { + let agent = await init(); + let req = agent.keepOpen(); + try { + await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); + } finally { + req.close(); + agent.close(); + done(); + } + }); +}); + +describe('PUT /integration with a correct request', () => { + it('should succeed', async done => { + let agent = await init(); + let req = agent.keepOpen(); + try { + await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); + await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationType.spotify, details: { secret: 'cat' } }, 200); + await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.spotify, details: { secret: 'cat' } }) + } finally { + req.close(); + agent.close(); + done(); + } + }); +}); + +describe('PUT /integration with wrong data', () => { + it('should fail', async done => { + let agent = await init(); + let req = agent.keepOpen(); + try { + await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); + await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {} }, 400); + } finally { + req.close(); + agent.close(); + done(); + } + }); +}); + +describe('DELETE /integration with a correct request', () => { + it('should succeed', async done => { + let agent = await init(); + let req = agent.keepOpen(); + try { + await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); + await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.spotify, details: {} }) + await helpers.deleteIntegration(req, 1, 200); + await helpers.checkIntegration(req, 1, 404); + } finally { + req.close(); + agent.close(); + done(); + } + }); +}); \ No newline at end of file diff --git a/server/test/integration/flows/helpers.js b/server/test/integration/flows/helpers.js index bbf427e..13f6b45 100644 --- a/server/test/integration/flows/helpers.js +++ b/server/test/integration/flows/helpers.js @@ -1,5 +1,6 @@ import { expect } from "chai"; import { sha512 } from "js-sha512"; +import { IntegrationType } from "../../../../client/src/api"; export async function initTestDB() { // Allow different database configs - but fall back to SQLite in memory if necessary. @@ -28,6 +29,7 @@ export async function createSong( .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; }); } @@ -42,6 +44,7 @@ export async function modifySong( .send(props) .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); + return res; }); } @@ -56,6 +59,7 @@ export async function checkSong( .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; }) } @@ -71,6 +75,7 @@ export async function createArtist( .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; }); } @@ -85,6 +90,7 @@ export async function modifyArtist( .send(props) .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); + return res; }); } @@ -99,6 +105,7 @@ export async function checkArtist( .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; }) } @@ -114,6 +121,7 @@ export async function createTag( .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; }); } @@ -128,6 +136,7 @@ export async function modifyTag( .send(props) .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); + return res; }); } @@ -142,6 +151,7 @@ export async function checkTag( .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; }) } @@ -157,6 +167,7 @@ export async function createAlbum( .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; }); } @@ -171,6 +182,7 @@ export async function modifyAlbum( .send(props) .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); + return res; }); } @@ -185,6 +197,7 @@ export async function checkAlbum( .then((res) => { expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; }) } @@ -203,6 +216,7 @@ export async function createUser( }); expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; } export async function login( @@ -217,6 +231,7 @@ export async function login( .send({}); expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; } export async function logout( @@ -229,4 +244,64 @@ export async function logout( .send({}); expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; +} + +export async function createIntegration( + req, + props = { name: "Integration", type: IntegrationType.Spotify, details: {} }, + expectStatus = undefined, + expectResponse = undefined +) { + await req + .post('/integration') + .send(props) + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; + }); +} + +export async function modifyIntegration( + req, + id = 1, + props = { name: "NewIntegration", type: IntegrationType.Spotify, details: {} }, + expectStatus = undefined, +) { + await req + .put('/integration/' + id) + .send(props) + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + return res; + }); +} + +export async function checkIntegration( + req, + id, + expectStatus = undefined, + expectResponse = undefined, +) { + await req + .get('/integration/' + id) + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; + }) +} + +export async function deleteIntegration( + req, + id, + expectStatus = undefined, +) { + await req + .delete('/integration/' + id) + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + return res; + }) } \ No newline at end of file -- 2.36.1 From 5a073fb3b8a42f060b0369b38203c50c2eec154b Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Mon, 16 Nov 2020 13:31:10 +0100 Subject: [PATCH 2/8] Further back-end work, add editor for integrations in front-end. --- client/src/api.ts | 22 +- client/src/assets/spotify_icon.svg | 1 + client/src/components/MainWindow.tsx | 5 + client/src/components/appbar/AppBar.tsx | 8 + .../src/components/common/StoreLinkIcon.tsx | 4 + client/src/components/windows/Windows.tsx | 6 + .../settings/IntegrationSettingsEditor.tsx | 287 ++++++++++++++ .../windows/settings/SettingsWindow.tsx | 59 +++ client/src/lib/backend/integrations.tsx | 63 +++ server/app.ts | 72 ++-- server/endpoints/{ModifyAlbum.ts => Album.ts} | 160 +++++++- server/endpoints/AlbumDetails.ts | 64 --- .../endpoints/{ModifyArtist.ts => Artist.ts} | 112 +++++- server/endpoints/ArtistDetails.ts | 41 -- server/endpoints/CreateAlbum.ts | 96 ----- server/endpoints/CreateArtist.ts | 70 ---- server/endpoints/CreateIntegration.ts | 43 -- server/endpoints/CreateSong.ts | 118 ------ server/endpoints/CreateTag.ts | 62 --- server/endpoints/DeleteIntegration.ts | 49 --- server/endpoints/DeleteTag.ts | 78 ---- server/endpoints/Integration.ts | 201 ++++++++++ server/endpoints/IntegrationDetails.ts | 34 -- server/endpoints/MergeTag.ts | 78 ---- server/endpoints/ModifyIntegration.ts | 52 --- server/endpoints/ModifySong.ts | 191 --------- server/endpoints/ModifyTag.ts | 66 ---- server/endpoints/Query.ts | 2 +- server/endpoints/RegisterUser.ts | 2 +- server/endpoints/Song.ts | 372 ++++++++++++++++++ server/endpoints/SongDetails.ts | 68 ---- server/endpoints/Tag.ts | 306 ++++++++++++++ server/endpoints/TagDetails.ts | 34 -- .../test/integration/flows/IntegrationFlow.js | 21 + server/test/integration/flows/helpers.js | 14 + 35 files changed, 1662 insertions(+), 1199 deletions(-) create mode 100644 client/src/assets/spotify_icon.svg create mode 100644 client/src/components/windows/settings/IntegrationSettingsEditor.tsx create mode 100644 client/src/components/windows/settings/SettingsWindow.tsx create mode 100644 client/src/lib/backend/integrations.tsx rename server/endpoints/{ModifyAlbum.ts => Album.ts} (51%) delete mode 100644 server/endpoints/AlbumDetails.ts rename server/endpoints/{ModifyArtist.ts => Artist.ts} (50%) delete mode 100644 server/endpoints/ArtistDetails.ts delete mode 100644 server/endpoints/CreateAlbum.ts delete mode 100644 server/endpoints/CreateArtist.ts delete mode 100644 server/endpoints/CreateIntegration.ts delete mode 100644 server/endpoints/CreateSong.ts delete mode 100644 server/endpoints/CreateTag.ts delete mode 100644 server/endpoints/DeleteIntegration.ts delete mode 100644 server/endpoints/DeleteTag.ts create mode 100644 server/endpoints/Integration.ts delete mode 100644 server/endpoints/IntegrationDetails.ts delete mode 100644 server/endpoints/MergeTag.ts delete mode 100644 server/endpoints/ModifyIntegration.ts delete mode 100644 server/endpoints/ModifySong.ts delete mode 100644 server/endpoints/ModifyTag.ts create mode 100644 server/endpoints/Song.ts delete mode 100644 server/endpoints/SongDetails.ts create mode 100644 server/endpoints/Tag.ts delete mode 100644 server/endpoints/TagDetails.ts diff --git a/client/src/api.ts b/client/src/api.ts index 63c3754..3d681e6 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -362,12 +362,19 @@ export enum IntegrationType { spotify = "spotify", } +export interface SpotifyIntegrationDetails { + clientId: string, + clientSecret: string, +} + +export type IntegrationDetails = SpotifyIntegrationDetails; + // Create a new integration (POST). export const CreateIntegrationEndpoint = '/integration'; export interface CreateIntegrationRequest { name: string, type: IntegrationType, - details: any, + details: IntegrationDetails, } export interface CreateIntegrationResponse { id: number; @@ -385,7 +392,7 @@ export const ModifyIntegrationEndpoint = '/integration/:id'; export interface ModifyIntegrationRequest { name?: string, type?: IntegrationType, - details?: any, + details?: IntegrationDetails, } export interface ModifyIntegrationResponse { } export function checkModifyIntegrationRequest(req: any): boolean { @@ -399,12 +406,21 @@ export interface IntegrationDetailsRequest { } export interface IntegrationDetailsResponse { name: string, type: IntegrationType, - details: any, + details: IntegrationDetails, } export function checkIntegrationDetailsRequest(req: any): boolean { return true; } +// List integrations (GET). +export const ListIntegrationsEndpoint = '/integration'; +export interface ListIntegrationsRequest { } +export interface ListIntegrationsItem extends IntegrationDetailsResponse { id: number } +export type ListIntegrationsResponse = ListIntegrationsItem[]; +export function checkListIntegrationsRequest(req: any): boolean { + return true; +} + // Delete integration (DELETE). export const DeleteIntegrationEndpoint = '/integration/:id'; export interface DeleteIntegrationRequest { } diff --git a/client/src/assets/spotify_icon.svg b/client/src/assets/spotify_icon.svg new file mode 100644 index 0000000..cfc993b --- /dev/null +++ b/client/src/assets/spotify_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index 2241615..32ae543 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -12,6 +12,7 @@ import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom'; import LoginWindow from './windows/login/LoginWindow'; import { useAuth } from '../lib/useAuth'; import RegisterWindow from './windows/register/RegisterWindow'; +import SettingsWindow from './windows/settings/SettingsWindow'; const darkTheme = createMuiTheme({ palette: { @@ -57,6 +58,10 @@ export default function MainWindow(props: any) { + + + + diff --git a/client/src/components/appbar/AppBar.tsx b/client/src/components/appbar/AppBar.tsx index b39601f..7b6f394 100644 --- a/client/src/components/appbar/AppBar.tsx +++ b/client/src/components/appbar/AppBar.tsx @@ -28,6 +28,8 @@ export function UserMenu(props: { onClose: () => void, }) { let auth = useAuth(); + let history = useHistory(); + const pos = props.open && props.position ? { left: props.position[0], top: props.position[1] } : { left: 0, top: 0 } @@ -41,6 +43,12 @@ export function UserMenu(props: { > {auth.user?.email || "Unknown user"} + { + props.onClose(); + history.replace('/settings') + }} + >User Settings { props.onClose(); diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx index 7359ab0..8b3478e 100644 --- a/client/src/components/common/StoreLinkIcon.tsx +++ b/client/src/components/common/StoreLinkIcon.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg'; +import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg'; export enum ExternalStore { GooglePlayMusic = "GPM", + Spotify = "Spotify", } export interface IProps { @@ -22,6 +24,8 @@ export default function StoreLinkIcon(props: any) { switch(whichStore) { case ExternalStore.GooglePlayMusic: return ; + case ExternalStore.Spotify: + return ; default: throw new Error("Unknown external store: " + whichStore) } diff --git a/client/src/components/windows/Windows.tsx b/client/src/components/windows/Windows.tsx index 9188b2f..b07b792 100644 --- a/client/src/components/windows/Windows.tsx +++ b/client/src/components/windows/Windows.tsx @@ -14,6 +14,7 @@ import { songGetters } from '../../lib/songGetters'; import ManageTagsWindow, { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow'; import { RegisterWindowReducer } from './register/RegisterWindow'; import { LoginWindowReducer } from './login/LoginWindow'; +import { SettingsWindowReducer } from './settings/SettingsWindow'; export enum WindowType { Query = "Query", @@ -24,6 +25,7 @@ export enum WindowType { ManageTags = "ManageTags", Login = "Login", Register = "Register", + Settings = "Settings", } export interface WindowState { } @@ -37,6 +39,7 @@ export const newWindowReducer = { [WindowType.ManageTags]: ManageTagsWindowReducer, [WindowType.Login]: LoginWindowReducer, [WindowType.Register]: RegisterWindowReducer, + [WindowType.Settings]: SettingsWindowReducer, } export const newWindowState = { @@ -94,4 +97,7 @@ export const newWindowState = { [WindowType.Register]: () => { return {} }, + [WindowType.Settings]: () => { + return {} + }, } \ No newline at end of file diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx new file mode 100644 index 0000000..6f966a5 --- /dev/null +++ b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx @@ -0,0 +1,287 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useAuth } from '../../../lib/useAuth'; +import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu } from '@material-ui/core'; +import { IntegrationDetails } from '../../../api'; +import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations'; +import AddIcon from '@material-ui/icons/Add'; +import EditIcon from '@material-ui/icons/Edit'; +import CheckIcon from '@material-ui/icons/Check'; +import DeleteIcon from '@material-ui/icons/Delete'; +import * as serverApi from '../../../api'; +import StoreLinkIcon, { ExternalStore } from '../../common/StoreLinkIcon'; +import { v4 as genUuid } from 'uuid'; +let _ = require('lodash') + +interface EditIntegrationProps { + integration: serverApi.IntegrationDetailsResponse, + original: serverApi.IntegrationDetailsResponse, + editing: boolean, + submitting: boolean, + onChange: (p: serverApi.IntegrationDetailsResponse, editing: boolean) => void, + onSubmit: () => void, + onDelete: () => void, +} + +function EditIntegration(props: EditIntegrationProps) { + let IntegrationHeaders: Record = { + [serverApi.IntegrationType.spotify]: + + Spotify + + } + + return + {IntegrationHeaders[props.integration.type]} + + Name: + {props.editing ? + props.onChange({ + ...props.integration, + name: e.target.value, + }, props.editing)} + /> : + {props.integration.name}} + + {props.integration.type === serverApi.IntegrationType.spotify && <> + + Client id: + {props.editing ? + props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientId: e.target.value, + } + }, props.editing)} + /> : + {props.integration.details.clientId}} + + + Client secret: + {props.editing ? + props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientSecret: e.target.value, + } + }, props.editing)} + /> : + {props.integration.details.clientSecret}} + + {!props.editing && !props.submitting && { props.onChange(props.integration, true); }} + >} + {props.editing && !props.submitting && { props.onSubmit(); }} + >} + {!props.submitting && { props.onDelete(); }} + >} + {props.submitting && } + } + +} + +function AddIntegrationMenu(props: { + position: null | number[], + open: boolean, + onClose: () => void, + onAdd: (type: serverApi.IntegrationType) => void, +}) { + const pos = props.open && props.position ? + { left: props.position[0], top: props.position[1] } + : { left: 0, top: 0 } + + return + { + props.onAdd(serverApi.IntegrationType.spotify); + props.onClose(); + }} + >Spotify + +} + +export default function IntegrationSettingsEditor(props: {}) { + interface EditorState { + id: string, //uniquely identifies this editor in the window. + upstreamId: number | null, //back-end ID for this integration if any. + integration: serverApi.IntegrationDetailsResponse, + original: serverApi.IntegrationDetailsResponse, + editing: boolean, + submitting: boolean, + } + let [editors, setEditors] = useState(null); + const [addMenuPos, setAddMenuPos] = React.useState(null); + + const onOpenAddMenu = (e: any) => { + setAddMenuPos([e.clientX, e.clientY]) + }; + const onCloseAddMenu = () => { + setAddMenuPos(null); + }; + + const submitEditor = (state: EditorState) => { + let integration = state.integration; + + if (state.upstreamId === null) { + createIntegration(integration).then((response: any) => { + if (!response.id) { + throw new Error('failed to submit integration.') + } + let cpy = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = false; + s.editing = false; + s.upstreamId = response.id; + } + }) + setEditors(cpy); + }) + } else { + modifyIntegration(state.upstreamId, integration).then(() => { + let cpy = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = false; + s.editing = false; + } + }) + setEditors(cpy); + }) + } + } + + const deleteEditor = (state: EditorState) => { + if(!state.upstreamId) { + throw new Error('Cannot delete integration: has no upstream') + } + deleteIntegration(state.upstreamId).then((response: any) => { + let cpy = _.cloneDeep(editors).filter( + (e: any) => e.id !== state.id + ); + setEditors(cpy); + }) + } + + useEffect(() => { + getIntegrations() + .then((integrations: serverApi.ListIntegrationsResponse) => { + setEditors(integrations.map((i: any, idx: any) => { + return { + integration: { ...i }, + original: { ...i }, + id: genUuid(), + editing: false, + submitting: false, + upstreamId: i.id, + } + })); + }); + }, []); + + // FIXME: add button should show a drop-down to choose a fixed integration type. + // Otherwise we need dynamic switching of the type's fields. + return <> + + {editors === null && } + {editors && <> + {editors.map((state: EditorState) => { + if (!editors) { + throw new Error('cannot change editors before loading integrations.') + } + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.integration = p; + s.editing = editing; + } + }) + setEditors(cpy); + }} + onSubmit={() => { + if (!editors) { + throw new Error('cannot submit editors before loading integrations.') + } + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = true; + } + }) + setEditors(cpy); + submitEditor(state); + }} + onDelete={() => { + if (!editors) { + throw new Error('cannot submit editors before loading integrations.') + } + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = true; + } + }) + setEditors(cpy); + deleteEditor(state); + }} + />)} + + + + } + + { + let cpy = _.cloneDeep(editors); + cpy.push({ + integration: { + type: serverApi.IntegrationType.spotify, + details: { + clientId: '', + clientSecret: '', + }, + name: '', + }, + original: null, + id: genUuid(), + editing: true, + submitting: false, + upstreamId: null, + }) + setEditors(cpy); + }} + /> + ; +} \ No newline at end of file diff --git a/client/src/components/windows/settings/SettingsWindow.tsx b/client/src/components/windows/settings/SettingsWindow.tsx new file mode 100644 index 0000000..02f0975 --- /dev/null +++ b/client/src/components/windows/settings/SettingsWindow.tsx @@ -0,0 +1,59 @@ +import React, { useReducer } from 'react'; +import { WindowState } from "../Windows"; +import { Box, Paper, Typography, TextField, Button } from "@material-ui/core"; +import { useHistory } from 'react-router'; +import { useAuth, Auth } from '../../../lib/useAuth'; +import Alert from '@material-ui/lab/Alert'; +import { Link } from 'react-router-dom'; +import IntegrationSettingsEditor from './IntegrationSettingsEditor'; + +export enum SettingsTab { + Integrations = 0, +} + +export interface SettingsWindowState extends WindowState { + activeTab: SettingsTab, +} +export enum SettingsWindowStateActions { + SetActiveTab = "SetActiveTab", +} +export function SettingsWindowReducer(state: SettingsWindowState, action: any) { + switch (action.type) { + case SettingsWindowStateActions.SetActiveTab: + return { ...state, activeTab: action.value } + default: + throw new Error("Unimplemented SettingsWindow state update.") + } +} + +export default function SettingsWindow(props: {}) { + const [state, dispatch] = useReducer(SettingsWindowReducer, { + activeTab: SettingsTab.Integrations, + }); + + return +} + +export function SettingsWindowControlled(props: { + state: SettingsWindowState, + dispatch: (action: any) => void, +}) { + let history: any = useHistory(); + let auth: Auth = useAuth(); + + return + + + + User Settings + Integrations + + + + + +} \ No newline at end of file diff --git a/client/src/lib/backend/integrations.tsx b/client/src/lib/backend/integrations.tsx new file mode 100644 index 0000000..04213fe --- /dev/null +++ b/client/src/lib/backend/integrations.tsx @@ -0,0 +1,63 @@ +import * as serverApi from '../../api'; + +export async function createIntegration(details: serverApi.CreateIntegrationRequest) { + const requestOpts = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(details), + }; + + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts) + if (!response.ok) { + throw new Error("Response to integration creation not OK: " + JSON.stringify(response)); + } + return await response.json(); +} + +export async function modifyIntegration(id: number, details: serverApi.ModifyIntegrationRequest) { + const requestOpts = { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(details), + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.ModifyIntegrationEndpoint.replace(':id', id.toString()), + requestOpts + ); + if (!response.ok) { + throw new Error("Response to integration modification not OK: " + JSON.stringify(response)); + } +} + +export async function deleteIntegration(id: number) { + const requestOpts = { + method: 'DELETE', + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.DeleteIntegrationEndpoint.replace(':id', id.toString()), + requestOpts + ); + if (!response.ok) { + throw new Error("Response to integration deletion not OK: " + JSON.stringify(response)); + } +} + +export async function getIntegrations() { + const requestOpts = { + method: 'GET', + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.ListIntegrationsEndpoint, + requestOpts + ); + + if (!response.ok) { + throw new Error("Response to integration list not OK: " + JSON.stringify(response)); + } + + let json = await response.json(); + return json; +} \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index df82b1d..d0d2682 100644 --- a/server/app.ts +++ b/server/app.ts @@ -2,32 +2,15 @@ const bodyParser = require('body-parser'); import * as api from '../client/src/api'; import Knex from 'knex'; -import { QueryEndpointHandler } from './endpoints/Query'; +import { Query } from './endpoints/Query'; -import { CreateArtistEndpointHandler } from './endpoints/CreateArtist'; -import { ArtistDetailsEndpointHandler } from './endpoints/ArtistDetails' -import { ModifyArtistEndpointHandler } from './endpoints/ModifyArtist'; +import { PostArtist, PutArtist, GetArtist } from './endpoints/Artist'; +import { PostAlbum, PutAlbum, GetAlbum } from './endpoints/Album'; +import { PostSong, PutSong, GetSong } from './endpoints/Song'; +import { PostTag, PutTag, GetTag, DeleteTag, MergeTag } from './endpoints/Tag'; +import { PostIntegration, PutIntegration, GetIntegration, DeleteIntegration, ListIntegrations } from './endpoints/Integration'; -import { ModifySongEndpointHandler } from './endpoints/ModifySong'; -import { SongDetailsEndpointHandler } from './endpoints/SongDetails'; -import { CreateSongEndpointHandler } from './endpoints/CreateSong'; - -import { CreateAlbumEndpointHandler } from './endpoints/CreateAlbum'; -import { ModifyAlbumEndpointHandler } from './endpoints/ModifyAlbum'; -import { AlbumDetailsEndpointHandler } from './endpoints/AlbumDetails'; - -import { CreateTagEndpointHandler } from './endpoints/CreateTag'; -import { ModifyTagEndpointHandler } from './endpoints/ModifyTag'; -import { DeleteTagEndpointHandler } from './endpoints/DeleteTag'; -import { MergeTagEndpointHandler } from './endpoints/MergeTag'; -import { TagDetailsEndpointHandler } from './endpoints/TagDetails'; - -import { RegisterUserEndpointHandler } from './endpoints/RegisterUser'; - -import { CreateIntegrationEndpointHandler } from './endpoints/CreateIntegration'; -import { ModifyIntegrationEndpointHandler } from './endpoints/ModifyIntegration'; -import { DeleteIntegrationEndpointHandler } from './endpoints/DeleteIntegration'; -import { IntegrationDetailsEndpointHandler } from './endpoints/IntegrationDetails'; +import { RegisterUser } from './endpoints/RegisterUser'; import * as endpointTypes from './endpoints/types'; import { sha512 } from 'js-sha512'; @@ -118,32 +101,33 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { } // Set up REST API endpoints - app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(CreateSongEndpointHandler)); - app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(ModifySongEndpointHandler)); - app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(SongDetailsEndpointHandler)); + app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(PostSong)); + app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(PutSong)); + app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(GetSong)); - app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(QueryEndpointHandler)); + app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(Query)); - app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(CreateArtistEndpointHandler)); - app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(ModifyArtistEndpointHandler)); - app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(ArtistDetailsEndpointHandler)); + app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(PostArtist)); + app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(PutArtist)); + app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(GetArtist)); - app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(CreateAlbumEndpointHandler)); - app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(ModifyAlbumEndpointHandler)); - app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(AlbumDetailsEndpointHandler)); + app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(PostAlbum)); + app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(PutAlbum)); + app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(GetAlbum)); - app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(CreateTagEndpointHandler)); - app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(ModifyTagEndpointHandler)); - app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(TagDetailsEndpointHandler)); - app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTagEndpointHandler)); - app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTagEndpointHandler)); + app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(PostTag)); + app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(PutTag)); + app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(GetTag)); + app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTag)); + app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTag)); - app.post(apiBaseUrl + api.CreateIntegrationEndpoint, checkLogin(), _invoke(CreateIntegrationEndpointHandler)); - app.put(apiBaseUrl + api.ModifyIntegrationEndpoint, checkLogin(), _invoke(ModifyIntegrationEndpointHandler)); - app.get(apiBaseUrl + api.IntegrationDetailsEndpoint, checkLogin(), _invoke(IntegrationDetailsEndpointHandler)); - app.delete(apiBaseUrl + api.DeleteIntegrationEndpoint, checkLogin(), _invoke(DeleteIntegrationEndpointHandler)); + app.post(apiBaseUrl + api.CreateIntegrationEndpoint, checkLogin(), _invoke(PostIntegration)); + app.put(apiBaseUrl + api.ModifyIntegrationEndpoint, checkLogin(), _invoke(PutIntegration)); + app.get(apiBaseUrl + api.IntegrationDetailsEndpoint, checkLogin(), _invoke(GetIntegration)); + app.delete(apiBaseUrl + api.DeleteIntegrationEndpoint, checkLogin(), _invoke(DeleteIntegration)); + app.get(apiBaseUrl + api.ListIntegrationsEndpoint, checkLogin(), _invoke(ListIntegrations)); - app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUserEndpointHandler)); + app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUser)); app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => { res.status(200).send({ userId: req.user.id }); }); diff --git a/server/endpoints/ModifyAlbum.ts b/server/endpoints/Album.ts similarity index 51% rename from server/endpoints/ModifyAlbum.ts rename to server/endpoints/Album.ts index 829f6c2..5442db7 100644 --- a/server/endpoints/ModifyAlbum.ts +++ b/server/endpoints/Album.ts @@ -1,11 +1,165 @@ import * as api from '../../client/src/api'; import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import Knex from 'knex'; +import asJson from '../lib/asJson'; -export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { +export const GetAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkAlbumDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + // Start transfers for songs, tags and artists. + // Also request the album itself. + const tagIdsPromise = knex.select('tagId') + .from('albums_tags') + .where({ 'albumId': req.params.id }) + .then((tags: any) => { + return tags.map((tag: any) => tag['tagId']) + }); + const songIdsPromise = knex.select('songId') + .from('songs_albums') + .where({ 'albumId': req.params.id }) + .then((songs: any) => { + return songs.map((song: any) => song['songId']) + }); + const artistIdsPromise = knex.select('artistId') + .from('artists_albums') + .where({ 'albumId': req.params.id }) + .then((artists: any) => { + return artists.map((artist: any) => artist['artistId']) + }); + const albumPromise = knex.select('name', 'storeLinks') + .from('albums') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((albums: any) => albums[0]); + + // Wait for the requests to finish. + const [album, tags, songs, artists] = + await Promise.all([albumPromise, tagIdsPromise, songIdsPromise, artistIdsPromise]); + + // Respond to the request. + if (album) { + const response: api.AlbumDetailsResponse = { + name: album['name'], + artistIds: artists, + tagIds: tags, + songIds: songs, + storeLinks: asJson(album['storeLinks']), + }; + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e); +} +} + +export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateAlbumRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateAlbumRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Album ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('id') + .from('artists') + .where({ 'user': userId }) + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .whereIn('id', reqObject.tagIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Wait for the requests to finish. + var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);; + + // Check that we found all artists and tags we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length)) { + const e: EndpointError = { + internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Create the album. + const albumId = (await trx('albums') + .insert({ + name: reqObject.name, + storeLinks: JSON.stringify(reqObject.storeLinks || []), + user: userId, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the artists via the linking table. + if (artists && artists.length) { + await trx('artists_albums').insert( + artists.map((artistId: number) => { + return { + artistId: artistId, + albumId: albumId, + } + }) + ) + } + + // Link the tags via the linking table. + if (tags && tags.length) { + await trx('albums_tags').insert( + tags.map((tagId: number) => { + return { + albumId: albumId, + tagId: tagId, + } + }) + ) + } + + // Respond to the request. + const responseObject: api.CreateSongResponse = { + id: albumId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) + } + + export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkModifyAlbumRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid ModifyAlbum request: ' + JSON.stringify(req.body), + internalMessage: 'Invalid PutAlbum request: ' + JSON.stringify(req.body), httpStatus: 400 }; throw e; @@ -13,7 +167,7 @@ export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res: const reqObject: api.ModifyAlbumRequest = req.body; const { id: userId } = req.user; - console.log("User ", userId, ": Modify Album ", reqObject); + console.log("User ", userId, ": Put Album ", reqObject); await knex.transaction(async (trx) => { try { diff --git a/server/endpoints/AlbumDetails.ts b/server/endpoints/AlbumDetails.ts deleted file mode 100644 index 635b210..0000000 --- a/server/endpoints/AlbumDetails.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; -import asJson from '../lib/asJson'; - -export const AlbumDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkAlbumDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid AlbumDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - // Start transfers for songs, tags and artists. - // Also request the album itself. - const tagIdsPromise = knex.select('tagId') - .from('albums_tags') - .where({ 'albumId': req.params.id }) - .then((tags: any) => { - return tags.map((tag: any) => tag['tagId']) - }); - const songIdsPromise = knex.select('songId') - .from('songs_albums') - .where({ 'albumId': req.params.id }) - .then((songs: any) => { - return songs.map((song: any) => song['songId']) - }); - const artistIdsPromise = knex.select('artistId') - .from('artists_albums') - .where({ 'albumId': req.params.id }) - .then((artists: any) => { - return artists.map((artist: any) => artist['artistId']) - }); - const albumPromise = knex.select('name', 'storeLinks') - .from('albums') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((albums: any) => albums[0]); - - // Wait for the requests to finish. - const [album, tags, songs, artists] = - await Promise.all([albumPromise, tagIdsPromise, songIdsPromise, artistIdsPromise]); - - // Respond to the request. - if (album) { - const response: api.AlbumDetailsResponse = { - name: album['name'], - artistIds: artists, - tagIds: tags, - songIds: songs, - storeLinks: asJson(album['storeLinks']), - }; - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e); -} -} \ No newline at end of file diff --git a/server/endpoints/ModifyArtist.ts b/server/endpoints/Artist.ts similarity index 50% rename from server/endpoints/ModifyArtist.ts rename to server/endpoints/Artist.ts index 3d56ee6..0363423 100644 --- a/server/endpoints/ModifyArtist.ts +++ b/server/endpoints/Artist.ts @@ -1,11 +1,117 @@ import * as api from '../../client/src/api'; import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import Knex from 'knex'; +import asJson from '../lib/asJson'; -export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { +export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkArtistDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetArtist request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const tagIds = Array.from(new Set((await knex.select('tagId') + .from('artists_tags') + .where({ 'artistId': req.params.id }) + ).map((tag: any) => tag['tagId']))); + + const results = await knex.select(['id', 'name', 'storeLinks']) + .from('artists') + .where({ 'user': userId }) + .where({ 'id': req.params.id }); + + if (results[0]) { + const response: api.ArtistDetailsResponse = { + name: results[0].name, + tagIds: tagIds, + storeLinks: asJson(results[0].storeLinks), + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} + +export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateArtistRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostArtist request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateArtistRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Create artist ", reqObject) + + await knex.transaction(async (trx) => { + try { + // Retrieve tag instances to link the artist to. + const tags: number[] = reqObject.tagIds ? + Array.from(new Set( + (await trx.select('id').from('tags') + .where({ 'user': userId }) + .whereIn('id', reqObject.tagIds)) + .map((tag: any) => tag['id']) + )) + : []; + + if (reqObject.tagIds && tags && tags.length !== reqObject.tagIds.length) { + const e: EndpointError = { + internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Create the artist. + const artistId = (await trx('artists') + .insert({ + name: reqObject.name, + storeLinks: JSON.stringify(reqObject.storeLinks || []), + user: userId, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the tags via the linking table. + if (tags && tags.length) { + await trx('artists_tags').insert( + tags.map((tagId: number) => { + return { + artistId: artistId, + tagId: tagId, + } + }) + ) + } + + const responseObject: api.CreateSongResponse = { + id: artistId + }; + await res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }); +} + + +export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkModifyArtistRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid ModifyArtist request: ' + JSON.stringify(req.body), + internalMessage: 'Invalid PutArtist request: ' + JSON.stringify(req.body), httpStatus: 400 }; throw e; @@ -13,7 +119,7 @@ export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res const reqObject: api.ModifyArtistRequest = req.body; const { id: userId } = req.user; - console.log("User ", userId, ": Modify Artist ", reqObject); + console.log("User ", userId, ": Put Artist ", reqObject); await knex.transaction(async (trx) => { try { diff --git a/server/endpoints/ArtistDetails.ts b/server/endpoints/ArtistDetails.ts deleted file mode 100644 index effe19f..0000000 --- a/server/endpoints/ArtistDetails.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; -import asJson from '../lib/asJson'; - -export const ArtistDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkArtistDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ArtistDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - const tagIds = Array.from(new Set((await knex.select('tagId') - .from('artists_tags') - .where({ 'artistId': req.params.id }) - ).map((tag: any) => tag['tagId']))); - - const results = await knex.select(['id', 'name', 'storeLinks']) - .from('artists') - .where({ 'user': userId }) - .where({ 'id': req.params.id }); - - if (results[0]) { - const response: api.ArtistDetailsResponse = { - name: results[0].name, - tagIds: tagIds, - storeLinks: asJson(results[0].storeLinks), - } - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e) - } -} \ No newline at end of file diff --git a/server/endpoints/CreateAlbum.ts b/server/endpoints/CreateAlbum.ts deleted file mode 100644 index 7172d5b..0000000 --- a/server/endpoints/CreateAlbum.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateAlbumEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateAlbumRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateAlbumRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create Album ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('id') - .from('artists') - .where({ 'user': userId }) - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .whereIn('id', reqObject.tagIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Wait for the requests to finish. - var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);; - - // Check that we found all artists and tags we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length)) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the album. - const albumId = (await trx('albums') - .insert({ - name: reqObject.name, - storeLinks: JSON.stringify(reqObject.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the artists via the linking table. - if (artists && artists.length) { - await trx('artists_albums').insert( - artists.map((artistId: number) => { - return { - artistId: artistId, - albumId: albumId, - } - }) - ) - } - - // Link the tags via the linking table. - if (tags && tags.length) { - await trx('albums_tags').insert( - tags.map((tagId: number) => { - return { - albumId: albumId, - tagId: tagId, - } - }) - ) - } - - // Respond to the request. - const responseObject: api.CreateSongResponse = { - id: albumId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/CreateArtist.ts b/server/endpoints/CreateArtist.ts deleted file mode 100644 index 0496c47..0000000 --- a/server/endpoints/CreateArtist.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateArtistEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateArtistRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateArtist request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateArtistRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create artist ", reqObject) - - await knex.transaction(async (trx) => { - try { - // Retrieve tag instances to link the artist to. - const tags: number[] = reqObject.tagIds ? - Array.from(new Set( - (await trx.select('id').from('tags') - .where({ 'user': userId }) - .whereIn('id', reqObject.tagIds)) - .map((tag: any) => tag['id']) - )) - : []; - - if (reqObject.tagIds && tags && tags.length !== reqObject.tagIds.length) { - const e: EndpointError = { - internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the artist. - const artistId = (await trx('artists') - .insert({ - name: reqObject.name, - storeLinks: JSON.stringify(reqObject.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the tags via the linking table. - if (tags && tags.length) { - await trx('artists_tags').insert( - tags.map((tagId: number) => { - return { - artistId: artistId, - tagId: tagId, - } - }) - ) - } - - const responseObject: api.CreateSongResponse = { - id: artistId - }; - await res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }); -} \ No newline at end of file diff --git a/server/endpoints/CreateIntegration.ts b/server/endpoints/CreateIntegration.ts deleted file mode 100644 index 6f6eef3..0000000 --- a/server/endpoints/CreateIntegration.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateIntegrationRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateIntegration request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateIntegrationRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create Integration ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Create the new integration. - var integration: any = { - name: reqObject.name, - user: userId, - type: reqObject.type, - details: JSON.stringify(reqObject.details), - } - const integrationId = (await trx('integrations') - .insert(integration) - .returning('id') // Needed for Postgres - )[0]; - - // Respond to the request. - const responseObject: api.CreateIntegrationResponse = { - id: integrationId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/CreateSong.ts b/server/endpoints/CreateSong.ts deleted file mode 100644 index a3c80c1..0000000 --- a/server/endpoints/CreateSong.ts +++ /dev/null @@ -1,118 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateSongEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateSongRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateSong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateSongRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create Song ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('id') - .from('artists') - .where({ 'user': userId }) - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .whereIn('id', reqObject.tagIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Start retrieving albums. - const albumIdsPromise = reqObject.albumIds ? - trx.select('id') - .from('albums') - .where({ 'user': userId }) - .whereIn('id', reqObject.albumIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Wait for the requests to finish. - var [artists, tags, albums] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdsPromise]);; - - // Check that we found all objects we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || - (reqObject.albumIds && albums.length !== reqObject.albumIds.length)) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for CreateSong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the song. - const songId = (await trx('songs') - .insert({ - title: reqObject.title, - storeLinks: JSON.stringify(reqObject.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the artists via the linking table. - if (artists && artists.length) { - await Promise.all( - artists.map((artistId: number) => { - return trx('songs_artists').insert({ - artistId: artistId, - songId: songId, - }) - }) - ) - } - - // Link the tags via the linking table. - if (tags && tags.length) { - await Promise.all( - tags.map((tagId: number) => { - return trx('songs_tags').insert({ - songId: songId, - tagId: tagId, - }) - }) - ) - } - - // Link the albums via the linking table. - if (albums && albums.length) { - await Promise.all( - albums.map((albumId: number) => { - return trx('songs_albums').insert({ - songId: songId, - albumId: albumId, - }) - }) - ) - } - - // Respond to the request. - const responseObject: api.CreateSongResponse = { - id: songId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/CreateTag.ts b/server/endpoints/CreateTag.ts deleted file mode 100644 index 1587bcf..0000000 --- a/server/endpoints/CreateTag.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const CreateTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid CreateTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateTagRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Create Tag ", reqObject); - - await knex.transaction(async (trx) => { - try { - // If applicable, retrieve the parent tag. - const maybeParent: number | undefined = - reqObject.parentId ? - (await trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'id': reqObject.parentId }))[0]['id'] : - undefined; - - // Check if the parent was found, if applicable. - if (reqObject.parentId && maybeParent !== reqObject.parentId) { - const e: EndpointError = { - internalMessage: 'Could not find parent tag for CreateTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the new tag. - var tag: any = { - name: reqObject.name, - user: userId, - }; - if (maybeParent) { - tag['parentId'] = maybeParent; - } - const tagId = (await trx('tags') - .insert(tag) - .returning('id') // Needed for Postgres - )[0]; - - // Respond to the request. - const responseObject: api.CreateTagResponse = { - id: tagId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/DeleteIntegration.ts b/server/endpoints/DeleteIntegration.ts deleted file mode 100644 index b0eff9b..0000000 --- a/server/endpoints/DeleteIntegration.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const DeleteIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkDeleteIntegrationRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.DeleteIntegrationRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Delete Integration ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving the integration itself. - const integrationId = await trx.select('id') - .from('integrations') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Check that we found all objects we need. - if (!integrationId) { - const e: EndpointError = { - internalMessage: 'Integration does not exist for DeleteIntegration request: ' + JSON.stringify(req.body), - httpStatus: 404 - }; - throw e; - } - - // Delete the integration. - await trx('integrations') - .where({ 'user': userId, 'id': integrationId }) - .del(); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/DeleteTag.ts b/server/endpoints/DeleteTag.ts deleted file mode 100644 index 3fd9ae3..0000000 --- a/server/endpoints/DeleteTag.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -async function getChildrenRecursive(id: number, userId: number, trx: any) { - const directChildren = (await trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'parentId': id })).map((r: any) => r.id); - - const indirectChildrenPromises = directChildren.map( - (child: number) => getChildrenRecursive(child, userId, trx) - ); - const indirectChildrenNested = await Promise.all(indirectChildrenPromises); - const indirectChildren = indirectChildrenNested.flat(); - - return [ - ...directChildren, - ...indirectChildren, - ] -} - -export const DeleteTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkDeleteTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.DeleteTagRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Delete Tag ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving any child tags. - const childTagsPromise = - getChildrenRecursive(req.params.id, userId, trx); - - // Start retrieving the tag itself. - const tagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [tag, children] = await Promise.all([tagPromise, childTagsPromise]); - - // Merge all IDs. - const toDelete = [ tag, ...children ]; - - // Check that we found all objects we need. - if (!tag) { - const e: EndpointError = { - internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Delete the tag and its children. - await trx('tags') - .where({ 'user': userId }) - .whereIn('id', toDelete) - .del(); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts new file mode 100644 index 0000000..b871ca3 --- /dev/null +++ b/server/endpoints/Integration.ts @@ -0,0 +1,201 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; +import asJson from '../lib/asJson'; + +export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Create the new integration. + var integration: any = { + name: reqObject.name, + user: userId, + type: reqObject.type, + details: JSON.stringify(reqObject.details), + } + const integrationId = (await trx('integrations') + .insert(integration) + .returning('id') // Needed for Postgres + )[0]; + + // Respond to the request. + const responseObject: api.CreateIntegrationResponse = { + id: integrationId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const GetIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkIntegrationDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const integration = (await knex.select(['id', 'name', 'type', 'details']) + .from('integrations') + .where({ 'user': userId, 'id': req.params.id }))[0]; + + if (integration) { + const response: api.IntegrationDetailsResponse = { + name: integration.name, + type: integration.type, + details: asJson(integration.details), + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} + +export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkIntegrationDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid ListIntegrations request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const integrations: api.ListIntegrationsResponse = ( + await knex.select(['id', 'name', 'type', 'details']) + .from('integrations') + .where({ user: userId }) + ).map((object: any) => { + return { + id: object.id, + name: object.name, + type: object.type, + details: asJson(object.details), + } + }) + + await res.send(integrations); + } catch (e) { + catchUnhandledErrors(e) + } +} + +export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkDeleteIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Delete Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving the integration itself. + const integrationId = await trx.select('id') + .from('integrations') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Check that we found all objects we need. + if (!integrationId) { + const e: EndpointError = { + internalMessage: 'Integration does not exist for DeleteIntegration request: ' + JSON.stringify(req.body), + httpStatus: 404 + }; + throw e; + } + + // Delete the integration. + await trx('integrations') + .where({ 'user': userId, 'id': integrationId }) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkModifyIntegrationRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PutIntegration request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.ModifyIntegrationRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Put Integration ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving the integration. + const integrationId = await trx.select('id') + .from('integrations') + .where({ 'user': userId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Check that we found all objects we need. + if (!integrationId) { + const e: EndpointError = { + internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body), + httpStatus: 404 + }; + throw e; + } + + // Modify the integration. + var update: any = {}; + if ("name" in reqObject) { update["name"] = reqObject.name; } + if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); } + if ("type" in reqObject) { update["type"] = reqObject.type; } + await trx('integrations') + .where({ 'user': userId, 'id': req.params.id }) + .update(update) + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/IntegrationDetails.ts b/server/endpoints/IntegrationDetails.ts deleted file mode 100644 index 709ff25..0000000 --- a/server/endpoints/IntegrationDetails.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const IntegrationDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkIntegrationDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid IntegrationDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - const integration = (await knex.select(['id', 'name', 'type', 'details']) - .from('integrations') - .where({ 'user': userId, 'id': req.params.id }))[0]; - - if (integration) { - const response: api.IntegrationDetailsResponse = { - name: integration.name, - type: integration.type, - details: JSON.parse(integration.details), - } - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e) - } -} \ No newline at end of file diff --git a/server/endpoints/MergeTag.ts b/server/endpoints/MergeTag.ts deleted file mode 100644 index ed776c5..0000000 --- a/server/endpoints/MergeTag.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const MergeTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkMergeTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.DeleteTagRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Merge Tag ", reqObject); - const fromId = req.params.id; - const toId = req.params.toId; - - await knex.transaction(async (trx) => { - try { - // Start retrieving the "from" tag. - const fromTagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: fromId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Start retrieving the "to" tag. - const toTagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: toId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]); - - // Check that we found all objects we need. - if (!fromTag || !toTag) { - const e: EndpointError = { - internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Assign new tag ID to any objects referencing the to-be-merged tag. - const cPromise = trx('tags') - .where({ 'user': userId }) - .where({ 'parentId': fromId }) - .update({ 'parentId': toId }); - const sPromise = trx('songs_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - const arPromise = trx('artists_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - const alPromise = trx('albums_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - await Promise.all([sPromise, arPromise, alPromise, cPromise]); - - // Delete the original tag. - await trx('tags') - .where({ 'user': userId }) - .where({ 'id': fromId }) - .del(); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/ModifyIntegration.ts b/server/endpoints/ModifyIntegration.ts deleted file mode 100644 index 0532ece..0000000 --- a/server/endpoints/ModifyIntegration.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const ModifyIntegrationEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyIntegrationRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ModifyIntegration request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.ModifyIntegrationRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Modify Integration ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving the integration. - const integrationId = await trx.select('id') - .from('integrations') - .where({ 'user': userId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Check that we found all objects we need. - if (!integrationId) { - const e: EndpointError = { - internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body), - httpStatus: 404 - }; - throw e; - } - - // Modify the integration. - var update: any = {}; - if ("name" in reqObject) { update["name"] = reqObject.name; } - if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); } - if ("type" in reqObject) { update["type"] = reqObject.type; } - await trx('integrations') - .where({ 'user': userId, 'id': req.params.id }) - .update(update) - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/ModifySong.ts b/server/endpoints/ModifySong.ts deleted file mode 100644 index 488b72f..0000000 --- a/server/endpoints/ModifySong.ts +++ /dev/null @@ -1,191 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifySongRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ModifySong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.ModifySongRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Modify Song ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Retrieve the song to be modified itself. - const songPromise = trx.select('id') - .from('songs') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('artistId') - .from('songs_artists') - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['artistId'])) : - (async () => { return undefined })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('songs_tags') - .whereIn('id', reqObject.tagIds) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return undefined })(); - - // Start retrieving albums. - const albumIdsPromise = reqObject.albumIds ? - trx.select('id') - .from('songs_albums') - .whereIn('id', reqObject.albumIds) - .then((as: any) => as.map((a: any) => a['albumId'])) : - (async () => { return undefined })(); - - // Wait for the requests to finish. - var [song, artists, tags, albums] = - await Promise.all([songPromise, artistIdsPromise, tagIdsPromise, albumIdsPromise]);; - - // Check that we found all objects we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || - (reqObject.albumIds && albums.length !== reqObject.albumIds.length) || - !song) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for ModifySong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Modify the song. - var update: any = {}; - if ("title" in reqObject) { update["title"] = reqObject.title; } - if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } - const modifySongPromise = trx('songs') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .update(update) - - // Remove unlinked artists. - // TODO: test this! - const removeUnlinkedArtists = artists ? trx('songs_artists') - .where({ 'songId': req.params.id }) - .whereNotIn('artistId', reqObject.artistIds || []) - .delete() : undefined; - - // Remove unlinked tags. - // TODO: test this! - const removeUnlinkedTags = tags ? trx('songs_tags') - .where({ 'songId': req.params.id }) - .whereNotIn('tagId', reqObject.tagIds || []) - .delete() : undefined; - - // Remove unlinked albums. - // TODO: test this! - const removeUnlinkedAlbums = albums ? trx('songs_albums') - .where({ 'songId': req.params.id }) - .whereNotIn('albumId', reqObject.albumIds || []) - .delete() : undefined; - - // Link new artists. - // TODO: test this! - const addArtists = artists ? trx('songs_artists') - .where({ 'songId': req.params.id }) - .then((as: any) => as.map((a: any) => a['artistId'])) - .then((doneArtistIds: number[]) => { - // Get the set of artists that are not yet linked - const toLink = artists.filter((id: number) => { - return !doneArtistIds.includes(id); - }); - const insertObjects = toLink.map((artistId: number) => { - return { - artistId: artistId, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_artists').insert(obj) - ) - ); - }) : undefined; - - // Link new tags. - // TODO: test this! - const addTags = tags ? trx('songs_tags') - .where({ 'songId': req.params.id }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) - .then((doneTagIds: number[]) => { - // Get the set of tags that are not yet linked - const toLink = tags.filter((id: number) => { - return !doneTagIds.includes(id); - }); - const insertObjects = toLink.map((tagId: number) => { - return { - tagId: tagId, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_tags').insert(obj) - ) - ); - }) : undefined; - - // Link new albums. - // TODO: test this! - const addAlbums = albums ? trx('songs_albums') - .where({ 'albumId': req.params.id }) - .then((as: any) => as.map((a: any) => a['albumId'])) - .then((doneAlbumIds: number[]) => { - // Get the set of albums that are not yet linked - const toLink = albums.filter((id: number) => { - return !doneAlbumIds.includes(id); - }); - const insertObjects = toLink.map((albumId: number) => { - return { - albumId: albumId, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_albums').insert(obj) - ) - ); - }) : undefined; - - // Wait for all operations to finish. - await Promise.all([ - modifySongPromise, - removeUnlinkedArtists, - removeUnlinkedTags, - removeUnlinkedAlbums, - addArtists, - addTags, - addAlbums, - ]); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/ModifyTag.ts b/server/endpoints/ModifyTag.ts deleted file mode 100644 index 6596958..0000000 --- a/server/endpoints/ModifyTag.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const ModifyTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ModifyTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.ModifyTagRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Modify Tag ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving the parent tag. - const parentTagPromise = reqObject.parentId ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'id': reqObject.parentId }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return [] })(); - - // Start retrieving the tag itself. - const tagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [tag, parent] = await Promise.all([tagPromise, parentTagPromise]);; - - // Check that we found all objects we need. - if ((reqObject.parentId && !parent) || - !tag) { - const e: EndpointError = { - internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Modify the tag. - await trx('tags') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .update({ - name: reqObject.name, - parentId: reqObject.parentId || null, - }) - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/Query.ts b/server/endpoints/Query.ts index 60111f3..f705516 100644 --- a/server/endpoints/Query.ts +++ b/server/endpoints/Query.ts @@ -258,7 +258,7 @@ async function getFullTag(knex: Knex, userId: number, tag: any): Promise { return await resolveTag(tag); } -export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { +export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkQueryRequest(req.body)) { const e: EndpointError = { internalMessage: 'Invalid Query request: ' + JSON.stringify(req.body), diff --git a/server/endpoints/RegisterUser.ts b/server/endpoints/RegisterUser.ts index 0782d90..1b4d824 100644 --- a/server/endpoints/RegisterUser.ts +++ b/server/endpoints/RegisterUser.ts @@ -4,7 +4,7 @@ import Knex from 'knex'; import { sha512 } from 'js-sha512'; -export const RegisterUserEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { +export const RegisterUser: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkRegisterUserRequest(req)) { const e: EndpointError = { internalMessage: 'Invalid RegisterUser request: ' + JSON.stringify(req.body), diff --git a/server/endpoints/Song.ts b/server/endpoints/Song.ts new file mode 100644 index 0000000..0583b5f --- /dev/null +++ b/server/endpoints/Song.ts @@ -0,0 +1,372 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; +import asJson from '../lib/asJson'; + +export const PostSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateSongRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateSongRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Song ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('id') + .from('artists') + .where({ 'user': userId }) + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .whereIn('id', reqObject.tagIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Start retrieving albums. + const albumIdsPromise = reqObject.albumIds ? + trx.select('id') + .from('albums') + .where({ 'user': userId }) + .whereIn('id', reqObject.albumIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Wait for the requests to finish. + var [artists, tags, albums] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdsPromise]);; + + // Check that we found all objects we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || + (reqObject.albumIds && albums.length !== reqObject.albumIds.length)) { + const e: EndpointError = { + internalMessage: 'Not all albums and/or artists and/or tags exist for CreateSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Create the song. + const songId = (await trx('songs') + .insert({ + title: reqObject.title, + storeLinks: JSON.stringify(reqObject.storeLinks || []), + user: userId, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the artists via the linking table. + if (artists && artists.length) { + await Promise.all( + artists.map((artistId: number) => { + return trx('songs_artists').insert({ + artistId: artistId, + songId: songId, + }) + }) + ) + } + + // Link the tags via the linking table. + if (tags && tags.length) { + await Promise.all( + tags.map((tagId: number) => { + return trx('songs_tags').insert({ + songId: songId, + tagId: tagId, + }) + }) + ) + } + + // Link the albums via the linking table. + if (albums && albums.length) { + await Promise.all( + albums.map((albumId: number) => { + return trx('songs_albums').insert({ + songId: songId, + albumId: albumId, + }) + }) + ) + } + + // Respond to the request. + const responseObject: api.CreateSongResponse = { + id: songId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const GetSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkSongDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const tagIdsPromise: Promise = knex.select('tagId') + .from('songs_tags') + .where({ 'songId': req.params.id }) + .then((ts: any) => { + return Array.from(new Set( + ts.map((tag: any) => tag['tagId']) + )); + }) + + const albumIdsPromise: Promise = knex.select('albumId') + .from('songs_albums') + .where({ 'songId': req.params.id }) + .then((as: any) => { + return Array.from(new Set( + as.map((album: any) => album['albumId']) + )); + }) + + const artistIdsPromise: Promise = knex.select('artistId') + .from('songs_artists') + .where({ 'songId': req.params.id }) + .then((as: any) => { + return Array.from(new Set( + as.map((artist: any) => artist['artistId']) + )); + }) + const songPromise = await knex.select(['id', 'title', 'storeLinks']) + .from('songs') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .then((ss: any) => ss[0]) + + const [tags, albums, artists, song] = + await Promise.all([tagIdsPromise, albumIdsPromise, artistIdsPromise, songPromise]); + + if (song) { + const response: api.SongDetailsResponse = { + title: song.title, + tagIds: tags, + artistIds: artists, + albumIds: albums, + storeLinks: asJson(song.storeLinks), + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} + + +export const PutSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkModifySongRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PutSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.ModifySongRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Put Song ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Retrieve the song to be modified itself. + const songPromise = trx.select('id') + .from('songs') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('artistId') + .from('songs_artists') + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['artistId'])) : + (async () => { return undefined })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('songs_tags') + .whereIn('id', reqObject.tagIds) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => { return undefined })(); + + // Start retrieving albums. + const albumIdsPromise = reqObject.albumIds ? + trx.select('id') + .from('songs_albums') + .whereIn('id', reqObject.albumIds) + .then((as: any) => as.map((a: any) => a['albumId'])) : + (async () => { return undefined })(); + + // Wait for the requests to finish. + var [song, artists, tags, albums] = + await Promise.all([songPromise, artistIdsPromise, tagIdsPromise, albumIdsPromise]);; + + // Check that we found all objects we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || + (reqObject.albumIds && albums.length !== reqObject.albumIds.length) || + !song) { + const e: EndpointError = { + internalMessage: 'Not all albums and/or artists and/or tags exist for ModifySong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Modify the song. + var update: any = {}; + if ("title" in reqObject) { update["title"] = reqObject.title; } + if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } + const modifySongPromise = trx('songs') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .update(update) + + // Remove unlinked artists. + // TODO: test this! + const removeUnlinkedArtists = artists ? trx('songs_artists') + .where({ 'songId': req.params.id }) + .whereNotIn('artistId', reqObject.artistIds || []) + .delete() : undefined; + + // Remove unlinked tags. + // TODO: test this! + const removeUnlinkedTags = tags ? trx('songs_tags') + .where({ 'songId': req.params.id }) + .whereNotIn('tagId', reqObject.tagIds || []) + .delete() : undefined; + + // Remove unlinked albums. + // TODO: test this! + const removeUnlinkedAlbums = albums ? trx('songs_albums') + .where({ 'songId': req.params.id }) + .whereNotIn('albumId', reqObject.albumIds || []) + .delete() : undefined; + + // Link new artists. + // TODO: test this! + const addArtists = artists ? trx('songs_artists') + .where({ 'songId': req.params.id }) + .then((as: any) => as.map((a: any) => a['artistId'])) + .then((doneArtistIds: number[]) => { + // Get the set of artists that are not yet linked + const toLink = artists.filter((id: number) => { + return !doneArtistIds.includes(id); + }); + const insertObjects = toLink.map((artistId: number) => { + return { + artistId: artistId, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_artists').insert(obj) + ) + ); + }) : undefined; + + // Link new tags. + // TODO: test this! + const addTags = tags ? trx('songs_tags') + .where({ 'songId': req.params.id }) + .then((ts: any) => ts.map((t: any) => t['tagId'])) + .then((doneTagIds: number[]) => { + // Get the set of tags that are not yet linked + const toLink = tags.filter((id: number) => { + return !doneTagIds.includes(id); + }); + const insertObjects = toLink.map((tagId: number) => { + return { + tagId: tagId, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_tags').insert(obj) + ) + ); + }) : undefined; + + // Link new albums. + // TODO: test this! + const addAlbums = albums ? trx('songs_albums') + .where({ 'albumId': req.params.id }) + .then((as: any) => as.map((a: any) => a['albumId'])) + .then((doneAlbumIds: number[]) => { + // Get the set of albums that are not yet linked + const toLink = albums.filter((id: number) => { + return !doneAlbumIds.includes(id); + }); + const insertObjects = toLink.map((albumId: number) => { + return { + albumId: albumId, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_albums').insert(obj) + ) + ); + }) : undefined; + + // Wait for all operations to finish. + await Promise.all([ + modifySongPromise, + removeUnlinkedArtists, + removeUnlinkedTags, + removeUnlinkedAlbums, + addArtists, + addTags, + addAlbums, + ]); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/SongDetails.ts b/server/endpoints/SongDetails.ts deleted file mode 100644 index 24e6267..0000000 --- a/server/endpoints/SongDetails.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; -import asJson from '../lib/asJson'; - -export const SongDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkSongDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid SongDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - const tagIdsPromise: Promise = knex.select('tagId') - .from('songs_tags') - .where({ 'songId': req.params.id }) - .then((ts: any) => { - return Array.from(new Set( - ts.map((tag: any) => tag['tagId']) - )); - }) - - const albumIdsPromise: Promise = knex.select('albumId') - .from('songs_albums') - .where({ 'songId': req.params.id }) - .then((as: any) => { - return Array.from(new Set( - as.map((album: any) => album['albumId']) - )); - }) - - const artistIdsPromise: Promise = knex.select('artistId') - .from('songs_artists') - .where({ 'songId': req.params.id }) - .then((as: any) => { - return Array.from(new Set( - as.map((artist: any) => artist['artistId']) - )); - }) - const songPromise = await knex.select(['id', 'title', 'storeLinks']) - .from('songs') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .then((ss: any) => ss[0]) - - const [tags, albums, artists, song] = - await Promise.all([tagIdsPromise, albumIdsPromise, artistIdsPromise, songPromise]); - - if (song) { - const response: api.SongDetailsResponse = { - title: song.title, - tagIds: tags, - artistIds: artists, - albumIds: albums, - storeLinks: asJson(song.storeLinks), - } - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e) - } -} \ No newline at end of file diff --git a/server/endpoints/Tag.ts b/server/endpoints/Tag.ts new file mode 100644 index 0000000..673ec0d --- /dev/null +++ b/server/endpoints/Tag.ts @@ -0,0 +1,306 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; + +export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkCreateTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateTagRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Tag ", reqObject); + + await knex.transaction(async (trx) => { + try { + // If applicable, retrieve the parent tag. + const maybeParent: number | undefined = + reqObject.parentId ? + (await trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ 'id': reqObject.parentId }))[0]['id'] : + undefined; + + // Check if the parent was found, if applicable. + if (reqObject.parentId && maybeParent !== reqObject.parentId) { + const e: EndpointError = { + internalMessage: 'Could not find parent tag for CreateTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Create the new tag. + var tag: any = { + name: reqObject.name, + user: userId, + }; + if (maybeParent) { + tag['parentId'] = maybeParent; + } + const tagId = (await trx('tags') + .insert(tag) + .returning('id') // Needed for Postgres + )[0]; + + // Respond to the request. + const responseObject: api.CreateTagResponse = { + id: tagId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +async function getChildrenRecursive(id: number, userId: number, trx: any) { + const directChildren = (await trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ 'parentId': id })).map((r: any) => r.id); + + const indirectChildrenPromises = directChildren.map( + (child: number) => getChildrenRecursive(child, userId, trx) + ); + const indirectChildrenNested = await Promise.all(indirectChildrenPromises); + const indirectChildren = indirectChildrenNested.flat(); + + return [ + ...directChildren, + ...indirectChildren, + ] +} + +export const DeleteTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkDeleteTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteTagRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Delete Tag ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving any child tags. + const childTagsPromise = + getChildrenRecursive(req.params.id, userId, trx); + + // Start retrieving the tag itself. + const tagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [tag, children] = await Promise.all([tagPromise, childTagsPromise]); + + // Merge all IDs. + const toDelete = [ tag, ...children ]; + + // Check that we found all objects we need. + if (!tag) { + const e: EndpointError = { + internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Delete the tag and its children. + await trx('tags') + .where({ 'user': userId }) + .whereIn('id', toDelete) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const GetTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkTagDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + const { id: userId } = req.user; + + try { + const results = await knex.select(['id', 'name', 'parentId']) + .from('tags') + .where({ 'user': userId }) + .where({ 'id': req.params.id }); + + if (results[0]) { + const response: api.TagDetailsResponse = { + name: results[0].name, + parentId: results[0].parentId || undefined, + } + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { + catchUnhandledErrors(e) + } +} + +export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkModifyTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PutTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.ModifyTagRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Put Tag ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving the parent tag. + const parentTagPromise = reqObject.parentId ? + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ 'id': reqObject.parentId }) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => { return [] })(); + + // Start retrieving the tag itself. + const tagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [tag, parent] = await Promise.all([tagPromise, parentTagPromise]);; + + // Check that we found all objects we need. + if ((reqObject.parentId && !parent) || + !tag) { + const e: EndpointError = { + internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Modify the tag. + await trx('tags') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .update({ + name: reqObject.name, + parentId: reqObject.parentId || null, + }) + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkMergeTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteTagRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Merge Tag ", reqObject); + const fromId = req.params.id; + const toId = req.params.toId; + + await knex.transaction(async (trx) => { + try { + // Start retrieving the "from" tag. + const fromTagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: fromId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Start retrieving the "to" tag. + const toTagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: toId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]); + + // Check that we found all objects we need. + if (!fromTag || !toTag) { + const e: EndpointError = { + internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Assign new tag ID to any objects referencing the to-be-merged tag. + const cPromise = trx('tags') + .where({ 'user': userId }) + .where({ 'parentId': fromId }) + .update({ 'parentId': toId }); + const sPromise = trx('songs_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + const arPromise = trx('artists_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + const alPromise = trx('albums_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + await Promise.all([sPromise, arPromise, alPromise, cPromise]); + + // Delete the original tag. + await trx('tags') + .where({ 'user': userId }) + .where({ 'id': fromId }) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/TagDetails.ts b/server/endpoints/TagDetails.ts deleted file mode 100644 index daacaae..0000000 --- a/server/endpoints/TagDetails.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -export const TagDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkTagDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid TagDetails request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - const { id: userId } = req.user; - - try { - const results = await knex.select(['id', 'name', 'parentId']) - .from('tags') - .where({ 'user': userId }) - .where({ 'id': req.params.id }); - - if (results[0]) { - const response: api.TagDetailsResponse = { - name: results[0].name, - parentId: results[0].parentId || undefined, - } - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { - catchUnhandledErrors(e) - } -} \ No newline at end of file diff --git a/server/test/integration/flows/IntegrationFlow.js b/server/test/integration/flows/IntegrationFlow.js index 366ab32..3b17a4c 100644 --- a/server/test/integration/flows/IntegrationFlow.js +++ b/server/test/integration/flows/IntegrationFlow.js @@ -102,4 +102,25 @@ describe('DELETE /integration with a correct request', () => { done(); } }); +}); + +describe('GET /integration list with a correct request', () => { + it('should succeed', async done => { + let agent = await init(); + let req = agent.keepOpen(); + try { + await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "B", type: IntegrationType.spotify, details: {} }, 200, { id: 2 }); + await helpers.createIntegration(req, { name: "C", type: IntegrationType.spotify, details: {} }, 200, { id: 3 }); + await helpers.listIntegrations(req, 200, [ + { id: 1, name: "A", type: IntegrationType.spotify, details: {} }, + { id: 2, name: "B", type: IntegrationType.spotify, details: {} }, + { id: 3, name: "C", type: IntegrationType.spotify, details: {} }, + ]); + } finally { + req.close(); + agent.close(); + done(); + } + }); }); \ No newline at end of file diff --git a/server/test/integration/flows/helpers.js b/server/test/integration/flows/helpers.js index 13f6b45..d905b55 100644 --- a/server/test/integration/flows/helpers.js +++ b/server/test/integration/flows/helpers.js @@ -293,6 +293,20 @@ export async function checkIntegration( }) } +export async function listIntegrations( + req, + expectStatus = undefined, + expectResponse = undefined, +) { + await req + .get('/integration') + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + expectResponse && expect(res.body).to.deep.equal(expectResponse); + return res; + }) +} + export async function deleteIntegration( req, id, -- 2.36.1 From 3d094c024b02d2e293486dea326a1214ad4dce87 Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Mon, 16 Nov 2020 16:35:26 +0100 Subject: [PATCH 3/8] Working towards spotify integration. --- client/src/api.ts | 6 +- .../settings/IntegrationSettingsEditor.tsx | 125 ++++++++++-------- .../src/lib/integration/spotify/spotify.tsx | 11 ++ server/integrations/spotifyClientCreds.ts | 15 +++ 4 files changed, 102 insertions(+), 55 deletions(-) create mode 100644 client/src/lib/integration/spotify/spotify.tsx create mode 100644 server/integrations/spotifyClientCreds.ts diff --git a/client/src/api.ts b/client/src/api.ts index 3d681e6..b4733c1 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -359,15 +359,15 @@ export const LoginEndpoint = "/login"; export const LogoutEndpoint = "/logout"; export enum IntegrationType { - spotify = "spotify", + SpotifyClientCredentials = "SpotifyClientCredentials", } -export interface SpotifyIntegrationDetails { +export interface SpotifyClientCredentialsDetails { clientId: string, clientSecret: string, } -export type IntegrationDetails = SpotifyIntegrationDetails; +export type IntegrationDetails = SpotifyClientCredentialsDetails; // Create a new integration (POST). export const CreateIntegrationEndpoint = '/integration'; diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx index 6f966a5..ccfb08a 100644 --- a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx +++ b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useAuth } from '../../../lib/useAuth'; -import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu } from '@material-ui/core'; -import { IntegrationDetails } from '../../../api'; +import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu, Button } from '@material-ui/core'; import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations'; import AddIcon from '@material-ui/icons/Add'; import EditIcon from '@material-ui/icons/Edit'; @@ -10,6 +9,7 @@ import DeleteIcon from '@material-ui/icons/Delete'; import * as serverApi from '../../../api'; import StoreLinkIcon, { ExternalStore } from '../../common/StoreLinkIcon'; import { v4 as genUuid } from 'uuid'; +import { getAuthToken } from '../../../lib/integration/spotify/spotify'; let _ = require('lodash') interface EditIntegrationProps { @@ -22,9 +22,42 @@ interface EditIntegrationProps { onDelete: () => void, } +function EditSpotifyClientCredentialsDetails(props: { + clientId: string, + clientSecret: string, + editing: boolean, + onChangeClientId: (v: string) => void, + onChangeClientSecret: (v: string) => void, +}) { + return + + Client id: + {props.editing ? + props.onChangeClientId(e.target.value)} + /> : + {props.clientId}} + + + Client secret: + {props.editing ? + props.onChangeClientSecret(e.target.value)} + /> : + {props.clientSecret}} + + ; +} + function EditIntegration(props: EditIntegrationProps) { let IntegrationHeaders: Record = { - [serverApi.IntegrationType.spotify]: + [serverApi.IntegrationType.SpotifyClientCredentials]: : {props.integration.name}} - {props.integration.type === serverApi.IntegrationType.spotify && <> - - Client id: - {props.editing ? - props.onChange({ - ...props.integration, - details: { - ...props.integration.details, - clientId: e.target.value, - } - }, props.editing)} - /> : - {props.integration.details.clientId}} - - - Client secret: - {props.editing ? - props.onChange({ - ...props.integration, - details: { - ...props.integration.details, - clientSecret: e.target.value, - } - }, props.editing)} - /> : - {props.integration.details.clientSecret}} - - {!props.editing && !props.submitting && { props.onChange(props.integration, true); }} - >} - {props.editing && !props.submitting && { props.onSubmit(); }} - >} - {!props.submitting && { props.onDelete(); }} - >} - {props.submitting && } - } + {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && + props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientId: v, + } + }, props.editing)} + onChangeClientSecret={(v: string) => props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientSecret: v, + } + }, props.editing)} + /> + } + {!props.editing && !props.submitting && { props.onChange(props.integration, true); }} + >} + {props.editing && !props.submitting && { props.onSubmit(); }} + >} + {!props.submitting && { props.onDelete(); }} + >} + {!props.submitting && } + {props.submitting && } } @@ -117,7 +138,7 @@ function AddIntegrationMenu(props: { > { - props.onAdd(serverApi.IntegrationType.spotify); + props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); props.onClose(); }} >Spotify @@ -176,7 +197,7 @@ export default function IntegrationSettingsEditor(props: {}) { } const deleteEditor = (state: EditorState) => { - if(!state.upstreamId) { + if (!state.upstreamId) { throw new Error('Cannot delete integration: has no upstream') } deleteIntegration(state.upstreamId).then((response: any) => { @@ -267,7 +288,7 @@ export default function IntegrationSettingsEditor(props: {}) { let cpy = _.cloneDeep(editors); cpy.push({ integration: { - type: serverApi.IntegrationType.spotify, + type: serverApi.IntegrationType.SpotifyClientCredentials, details: { clientId: '', clientSecret: '', diff --git a/client/src/lib/integration/spotify/spotify.tsx b/client/src/lib/integration/spotify/spotify.tsx new file mode 100644 index 0000000..02aa5ca --- /dev/null +++ b/client/src/lib/integration/spotify/spotify.tsx @@ -0,0 +1,11 @@ +export async function getAuthToken(clientId: string, clientSecret: string) { + let requestOpts = { + method: "POST", + headers: { "Authorization": "Basic " + clientId + ":" + clientSecret }, + } + + const response = await fetch("https://accounts.spotify.com/api/token?grant_type=client_credentials", requestOpts) + return await response.json(); +} + +export default {} \ No newline at end of file diff --git a/server/integrations/spotifyClientCreds.ts b/server/integrations/spotifyClientCreds.ts new file mode 100644 index 0000000..7bcd887 --- /dev/null +++ b/server/integrations/spotifyClientCreds.ts @@ -0,0 +1,15 @@ +// The authorization token to use with the Spotify API. +// Will need to be refreshed once in a while. +let authToken: string | null = null; + +export async function getAuthToken(clientId: string, clientSecret: string) { + let requestOpts = { + method: "POST", + headers: { "Authorization": "Basic " + clientId + ":" + clientSecret }, + } + + const response = await fetch("https://accounts.spotify.com/api/token?grant_type=client_credentials", requestOpts) + return await response.json(); +} + +export async function \ No newline at end of file -- 2.36.1 From e9d6d4055c0e1ffdd6bb8fa7258b53db5b2219c3 Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Mon, 16 Nov 2020 23:37:10 +0100 Subject: [PATCH 4/8] Got quick n dirty request to Spotify API working. --- .../settings/IntegrationSettingsEditor.tsx | 4 +- .../src/lib/integration/spotify/spotify.tsx | 11 -- .../spotify/spotifyClientCreds.tsx | 14 +++ server/app.ts | 4 + server/integrations/spotifyClientCreds.ts | 68 +++++++++-- server/package-lock.json | 113 +++++++++++++++++- server/package.json | 4 + 7 files changed, 192 insertions(+), 26 deletions(-) delete mode 100644 client/src/lib/integration/spotify/spotify.tsx create mode 100644 client/src/lib/integration/spotify/spotifyClientCreds.tsx diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx index ccfb08a..7bd4615 100644 --- a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx +++ b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx @@ -9,7 +9,7 @@ import DeleteIcon from '@material-ui/icons/Delete'; import * as serverApi from '../../../api'; import StoreLinkIcon, { ExternalStore } from '../../common/StoreLinkIcon'; import { v4 as genUuid } from 'uuid'; -import { getAuthToken } from '../../../lib/integration/spotify/spotify'; +import { testSpotify } from '../../../lib/integration/spotify/spotifyClientCreds'; let _ = require('lodash') interface EditIntegrationProps { @@ -113,7 +113,7 @@ function EditIntegration(props: EditIntegrationProps) { onClick={() => { props.onDelete(); }} >} {!props.submitting && } {props.submitting && } diff --git a/client/src/lib/integration/spotify/spotify.tsx b/client/src/lib/integration/spotify/spotify.tsx deleted file mode 100644 index 02aa5ca..0000000 --- a/client/src/lib/integration/spotify/spotify.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export async function getAuthToken(clientId: string, clientSecret: string) { - let requestOpts = { - method: "POST", - headers: { "Authorization": "Basic " + clientId + ":" + clientSecret }, - } - - const response = await fetch("https://accounts.spotify.com/api/token?grant_type=client_credentials", requestOpts) - return await response.json(); -} - -export default {} \ No newline at end of file diff --git a/client/src/lib/integration/spotify/spotifyClientCreds.tsx b/client/src/lib/integration/spotify/spotifyClientCreds.tsx new file mode 100644 index 0000000..641c106 --- /dev/null +++ b/client/src/lib/integration/spotify/spotifyClientCreds.tsx @@ -0,0 +1,14 @@ +export async function testSpotify() { + const requestOpts = { + method: 'GET', + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + '/spotifycc/v1/search?q=queens&type=artist', + requestOpts + ); + if (!response.ok) { + throw new Error("Response to tag merge not OK: " + JSON.stringify(response)); + } + console.log("Spotify response: ", response); +} \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index d0d2682..09d0388 100644 --- a/server/app.ts +++ b/server/app.ts @@ -14,6 +14,7 @@ import { RegisterUser } from './endpoints/RegisterUser'; import * as endpointTypes from './endpoints/types'; import { sha512 } from 'js-sha512'; +import { useSpotifyClientCreds } from './integrations/spotifyClientCreds'; // For authentication var passport = require('passport'); @@ -100,6 +101,9 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { } } + // Set up integration proxies + useSpotifyClientCreds(app); + // Set up REST API endpoints app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(PostSong)); app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(PutSong)); diff --git a/server/integrations/spotifyClientCreds.ts b/server/integrations/spotifyClientCreds.ts index 7bcd887..5ebfd70 100644 --- a/server/integrations/spotifyClientCreds.ts +++ b/server/integrations/spotifyClientCreds.ts @@ -1,15 +1,65 @@ +const { createProxyMiddleware } = require('http-proxy-middleware'); +let axios = require('axios') +let qs = require('querystring') + // The authorization token to use with the Spotify API. // Will need to be refreshed once in a while. -let authToken: string | null = null; +var authToken: string | null = null; + +async function updateToken(clientId: string, clientSecret: string) { + if (authToken) { return; } + + let buf = Buffer.from(clientId + ':' + clientSecret) + let encoded = buf.toString('base64'); + + let response = await axios.post( + 'https://accounts.spotify.com/api/token', + qs.stringify({ 'grant_type': 'client_credentials' }), + { + 'headers': { + 'Authorization': 'Basic ' + encoded, + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + authToken = (await response).data.access_token; +} -export async function getAuthToken(clientId: string, clientSecret: string) { - let requestOpts = { - method: "POST", - headers: { "Authorization": "Basic " + clientId + ":" + clientSecret }, - } +let onProxyReq = (proxyReq: any, req: any, res: any) => { + proxyReq.setHeader("Authorization", "Bearer " + req._access_token) - const response = await fetch("https://accounts.spotify.com/api/token?grant_type=client_credentials", requestOpts) - return await response.json(); + console.log("Proxying request", + { + 'path': req.path, + 'originalUrl': req.originalUrl, + 'baseUrl': req.baseUrl, + }, + { + 'path': proxyReq.path, + 'originalUrl': proxyReq.originalUrl, + 'baseUrl': req.baseUrl, + }, + ); } -export async function \ No newline at end of file +export function useSpotifyClientCreds(app: any) { + // First add a layer which creates a token and saves it in the request. + app.use((req: any, res: any, next: any) => { + updateToken('c3e5e605e7814cdf94cd86eeba6f4c4f', '5d870c84a3c34aa3a4cf803aa95cb96a') + .then(() => { + req._access_token = authToken; + next(); + }) + }) + app.use( + '/spotifycc', + createProxyMiddleware({ + target: 'https://api.spotify.com/', + changeOrigin: true, + onProxyReq: onProxyReq, + logLevel: 'debug', + pathRewrite: { '^/spotifycc': '' }, + }) + ) +} \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 8b45277..7d49ca9 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -24,6 +24,14 @@ "xml2js": "^0.4.19" }, "dependencies": { + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -69,6 +77,14 @@ "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz", "integrity": "sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw==" }, + "@types/http-proxy": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.4.tgz", + "integrity": "sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==", + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "14.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.2.tgz", @@ -304,11 +320,18 @@ "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==" }, "axios": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", - "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz", + "integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==", "requires": { - "follow-redirects": "1.5.10" + "follow-redirects": "^1.10.0" + }, + "dependencies": { + "follow-redirects": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", + "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" + } } }, "balanced-match": { @@ -1034,6 +1057,11 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -1654,6 +1682,68 @@ "toidentifier": "1.0.0" } }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-proxy-middleware": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-1.0.6.tgz", + "integrity": "sha512-NyL6ZB6cVni7pl+/IT2W0ni5ME00xR0sN27AQZZrpKn1b+qRh+mLbBxIq9Cq1oGfmTc7BUq4HB77mxwCaxAYNg==", + "requires": { + "@types/http-proxy": "^1.17.4", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "lodash": "^4.17.20", + "micromatch": "^4.0.2" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + } + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -2474,6 +2564,11 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz", "integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==" }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, "node-gyp": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz", @@ -3052,6 +3147,11 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, "random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -3206,6 +3306,11 @@ } } }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, "resolve": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", diff --git a/server/package.json b/server/package.json index 762d412..a05b69b 100644 --- a/server/package.json +++ b/server/package.json @@ -8,11 +8,13 @@ "test": "ts-node node_modules/jasmine/bin/jasmine --config=test/jasmine.json" }, "dependencies": { + "axios": "^0.21.0", "body-parser": "^1.18.3", "chai": "^4.2.0", "chai-http": "^4.3.0", "express": "^4.16.4", "express-session": "^1.17.1", + "http-proxy-middleware": "^1.0.6", "jasmine": "^3.5.0", "js-sha512": "^0.8.0", "knex": "^0.21.5", @@ -20,11 +22,13 @@ "mssql": "^6.2.1", "mysql": "^2.18.1", "mysql2": "^2.1.0", + "node-fetch": "^2.6.1", "nodemon": "^2.0.4", "oracledb": "^5.0.0", "passport": "^0.4.1", "passport-local": "^1.0.0", "pg": "^8.3.3", + "querystring": "^0.2.0", "sqlite3": "^5.0.0", "ts-node": "^8.10.2", "typescript": "~3.7.2" -- 2.36.1 From b4e4ac0162a4fcf235065031306bf8d67fa20fae Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Tue, 17 Nov 2020 12:42:56 +0100 Subject: [PATCH 5/8] Made Spotify API request correctly depend on the integration settings in the DB. --- .../settings/IntegrationSettingsEditor.tsx | 6 +- .../spotify/spotifyClientCreds.tsx | 4 +- server/app.ts | 4 +- server/integrations/integrations.ts | 110 ++++++++++++++++++ server/integrations/spotifyClientCreds.ts | 65 ----------- 5 files changed, 118 insertions(+), 71 deletions(-) create mode 100644 server/integrations/integrations.ts delete mode 100644 server/integrations/spotifyClientCreds.ts diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx index 7bd4615..18df746 100644 --- a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx +++ b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx @@ -13,6 +13,7 @@ import { testSpotify } from '../../../lib/integration/spotify/spotifyClientCreds let _ = require('lodash') interface EditIntegrationProps { + upstreamId: number | null, integration: serverApi.IntegrationDetailsResponse, original: serverApi.IntegrationDetailsResponse, editing: boolean, @@ -112,8 +113,8 @@ function EditIntegration(props: EditIntegrationProps) { {!props.submitting && { props.onDelete(); }} >} - {!props.submitting && } {props.submitting && } @@ -231,6 +232,7 @@ export default function IntegrationSettingsEditor(props: {}) { {editors === null && } {editors && <> {editors.map((state: EditorState) => { } // Set up integration proxies - useSpotifyClientCreds(app); + app.use('/integrations', checkLogin(), createIntegrations(knex)); // Set up REST API endpoints app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(PostSong)); diff --git a/server/integrations/integrations.ts b/server/integrations/integrations.ts new file mode 100644 index 0000000..1b34416 --- /dev/null +++ b/server/integrations/integrations.ts @@ -0,0 +1,110 @@ +import Knex from "knex"; +import { IntegrationType } from "../../client/src/api"; + +const { createProxyMiddleware } = require('http-proxy-middleware'); +let axios = require('axios') +let qs = require('querystring') + +async function getSpotifyCCAuthToken(clientId: string, clientSecret: string) { + console.log("Details: ", clientId, clientSecret); + + let buf = Buffer.from(clientId + ':' + clientSecret) + let encoded = buf.toString('base64'); + + let response = await axios.post( + 'https://accounts.spotify.com/api/token', + qs.stringify({ 'grant_type': 'client_credentials' }), + { + 'headers': { + 'Authorization': 'Basic ' + encoded, + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + if (response.status != 200) { + throw new Error("Unable to get a Spotify auth token.") + } + + return (await response).data.access_token; +} + +export function createIntegrations(knex: Knex) { + // This will enable the app to redirect requests like: + // /integrations/5/v1/search?q=query + // To the external API represented by integration 5, e.g. for spotify: + // https://api.spotify.com/v1/search?q=query + // Requests need to already have a .user.id set. + + let proxySpotifyCC = createProxyMiddleware({ + target: 'https://api.spotify.com/', + changeOrigin: true, + logLevel: 'debug', + pathRewrite: (path: string, req: any) => { + // Remove e.g. "/integrations/5" + console.log("Rewrite URL:", path); + return path.replace(/^\/integrations\/[0-9]+/, ''); + } + }); + + // In the first layer, retrieve integration details and save details + // in the request. + return async (req: any, res: any, next: any) => { + // Determine the integration to use. + req._integrationId = parseInt(req.url.match(/^\/([0-9]+)/)[1]); + console.log("URL:", req.url, 'match:', req._integrationId) + if (!req._integrationId) { + res.status(400).send({ reason: "An integration ID should be provided in the URL." }); + return; + } + req._integration = (await knex.select(['id', 'name', 'type', 'details']) + .from('integrations') + .where({ 'user': req.user.id, 'id': req._integrationId }))[0]; + if (!req._integration) { + res.status(404).send(); + return; + } + + req._integration.details = JSON.parse(req._integration.details); + + switch (req._integration.type) { + case IntegrationType.SpotifyClientCredentials: { + console.log("Integration: ", req._integration) + // FIXME: persist the token + req._access_token = await getSpotifyCCAuthToken( + req._integration.details.clientId, + req._integration.details.clientSecret, + ) + if (!req._access_token) { + res.status(500).send({ reason: "Unable to get Spotify auth token." }) + } + req.headers["Authorization"] = "Bearer " + req._access_token; + return proxySpotifyCC(req, res, next); + } + default: { + res.status(500).send({ reason: "Unsupported integration type " + req._integration.type }) + } + } + }; + + + + // // First add a layer which creates a token and saves it in the request. + // app.use((req: any, res: any, next: any) => { + // updateToken('c3e5e605e7814cdf94cd86eeba6f4c4f', '5d870c84a3c34aa3a4cf803aa95cb96a') + // .then(() => { + // req._access_token = authToken; + // next(); + // }) + // }) + // app.use( + // '/spotifycc', + // createProxyMiddleware({ + // target: 'https://api.spotify.com/', + // changeOrigin: true, + // onProxyReq: onProxyReq, + // logLevel: 'debug', + // pathRewrite: { '^/spotifycc': '' }, + // }) + // ) +} \ No newline at end of file diff --git a/server/integrations/spotifyClientCreds.ts b/server/integrations/spotifyClientCreds.ts deleted file mode 100644 index 5ebfd70..0000000 --- a/server/integrations/spotifyClientCreds.ts +++ /dev/null @@ -1,65 +0,0 @@ -const { createProxyMiddleware } = require('http-proxy-middleware'); -let axios = require('axios') -let qs = require('querystring') - -// The authorization token to use with the Spotify API. -// Will need to be refreshed once in a while. -var authToken: string | null = null; - -async function updateToken(clientId: string, clientSecret: string) { - if (authToken) { return; } - - let buf = Buffer.from(clientId + ':' + clientSecret) - let encoded = buf.toString('base64'); - - let response = await axios.post( - 'https://accounts.spotify.com/api/token', - qs.stringify({ 'grant_type': 'client_credentials' }), - { - 'headers': { - 'Authorization': 'Basic ' + encoded, - 'Content-Type': 'application/x-www-form-urlencoded' - } - } - ); - - authToken = (await response).data.access_token; -} - -let onProxyReq = (proxyReq: any, req: any, res: any) => { - proxyReq.setHeader("Authorization", "Bearer " + req._access_token) - - console.log("Proxying request", - { - 'path': req.path, - 'originalUrl': req.originalUrl, - 'baseUrl': req.baseUrl, - }, - { - 'path': proxyReq.path, - 'originalUrl': proxyReq.originalUrl, - 'baseUrl': req.baseUrl, - }, - ); -} - -export function useSpotifyClientCreds(app: any) { - // First add a layer which creates a token and saves it in the request. - app.use((req: any, res: any, next: any) => { - updateToken('c3e5e605e7814cdf94cd86eeba6f4c4f', '5d870c84a3c34aa3a4cf803aa95cb96a') - .then(() => { - req._access_token = authToken; - next(); - }) - }) - app.use( - '/spotifycc', - createProxyMiddleware({ - target: 'https://api.spotify.com/', - changeOrigin: true, - onProxyReq: onProxyReq, - logLevel: 'debug', - pathRewrite: { '^/spotifycc': '' }, - }) - ) -} \ No newline at end of file -- 2.36.1 From 8111633a027b99f1d5e6de0711848a0661b25db7 Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Tue, 17 Nov 2020 15:39:15 +0100 Subject: [PATCH 6/8] Persist authentication, make integration editor look good --- .../settings/IntegrationSettingsEditor.tsx | 252 +++++++++--------- client/src/lib/useAuth.tsx | 33 ++- 2 files changed, 162 insertions(+), 123 deletions(-) diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx index 18df746..0210d58 100644 --- a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx +++ b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useAuth } from '../../../lib/useAuth'; -import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu, Button } from '@material-ui/core'; +import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions } from '@material-ui/core'; import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations'; import AddIcon from '@material-ui/icons/Add'; import EditIcon from '@material-ui/icons/Edit'; @@ -31,93 +31,100 @@ function EditSpotifyClientCredentialsDetails(props: { onChangeClientSecret: (v: string) => void, }) { return - - Client id: - {props.editing ? - props.onChangeClientId(e.target.value)} - /> : - {props.clientId}} + + props.onChangeClientId(e.target.value)} + /> - - Client secret: - {props.editing ? - props.onChangeClientSecret(e.target.value)} - /> : - {props.clientSecret}} + + props.onChangeClientSecret(e.target.value)} + /> ; } function EditIntegration(props: EditIntegrationProps) { let IntegrationHeaders: Record = { - [serverApi.IntegrationType.SpotifyClientCredentials]: - - Spotify - + [serverApi.IntegrationType.SpotifyClientCredentials]: + + + Spotify + } - return - {IntegrationHeaders[props.integration.type]} - - Name: - {props.editing ? + return + + + + props.onChange({ ...props.integration, name: e.target.value, }, props.editing)} - /> : - {props.integration.name}} - - {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && - props.onChange({ - ...props.integration, - details: { - ...props.integration.details, - clientId: v, - } - }, props.editing)} - onChangeClientSecret={(v: string) => props.onChange({ - ...props.integration, - details: { - ...props.integration.details, - clientSecret: v, - } - }, props.editing)} - /> - } - {!props.editing && !props.submitting && { props.onChange(props.integration, true); }} - >} - {props.editing && !props.submitting && { props.onSubmit(); }} - >} - {!props.submitting && { props.onDelete(); }} - >} - {!props.submitting && !props.editing && props.upstreamId !== null && } - {props.submitting && } - + /> + + {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && + props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientId: v, + } + }, props.editing)} + onChangeClientSecret={(v: string) => props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientSecret: v, + } + }, props.editing)} + /> + } + + + {!props.editing && !props.submitting && { props.onChange(props.integration, true); }} + >} + {props.editing && !props.submitting && { props.onSubmit(); }} + >} + {!props.submitting && { props.onDelete(); }} + >} + {!props.submitting && !props.editing && props.upstreamId !== null && } + {props.submitting && } + + } function AddIntegrationMenu(props: { @@ -198,10 +205,11 @@ export default function IntegrationSettingsEditor(props: {}) { } const deleteEditor = (state: EditorState) => { - if (!state.upstreamId) { - throw new Error('Cannot delete integration: has no upstream') - } - deleteIntegration(state.upstreamId).then((response: any) => { + let promise: Promise = state.upstreamId ? + deleteIntegration(state.upstreamId) : + (async () => {})(); + + promise.then((response: any) => { let cpy = _.cloneDeep(editors).filter( (e: any) => e.id !== state.id ); @@ -225,62 +233,62 @@ export default function IntegrationSettingsEditor(props: {}) { }); }, []); - // FIXME: add button should show a drop-down to choose a fixed integration type. - // Otherwise we need dynamic switching of the type's fields. return <> {editors === null && } - {editors && <> - {editors.map((state: EditorState) => { - if (!editors) { - throw new Error('cannot change editors before loading integrations.') - } - let cpy: EditorState[] = _.cloneDeep(editors); - cpy.forEach((s: any) => { - if (s.id === state.id) { - s.integration = p; - s.editing = editing; + {editors && + {editors.map((state: EditorState) => + { + if (!editors) { + throw new Error('cannot change editors before loading integrations.') } - }) - setEditors(cpy); - }} - onSubmit={() => { - if (!editors) { - throw new Error('cannot submit editors before loading integrations.') - } - let cpy: EditorState[] = _.cloneDeep(editors); - cpy.forEach((s: any) => { - if (s.id === state.id) { - s.submitting = true; + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.integration = p; + s.editing = editing; + } + }) + setEditors(cpy); + }} + onSubmit={() => { + if (!editors) { + throw new Error('cannot submit editors before loading integrations.') } - }) - setEditors(cpy); - submitEditor(state); - }} - onDelete={() => { - if (!editors) { - throw new Error('cannot submit editors before loading integrations.') - } - let cpy: EditorState[] = _.cloneDeep(editors); - cpy.forEach((s: any) => { - if (s.id === state.id) { - s.submitting = true; + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = true; + } + }) + setEditors(cpy); + submitEditor(state); + }} + onDelete={() => { + if (!editors) { + throw new Error('cannot submit editors before loading integrations.') } - }) - setEditors(cpy); - deleteEditor(state); - }} - />)} + let cpy: EditorState[] = _.cloneDeep(editors); + cpy.forEach((s: any) => { + if (s.id === state.id) { + s.submitting = true; + } + }) + setEditors(cpy); + deleteEditor(state); + }} + /> + )} - } + } { return useContext(authContext); }; +function persistAuth(auth: AuthUser | null) { + let s = window.sessionStorage; + + if(auth === null) { + s.removeItem('userId'); + s.removeItem('userEmail'); + return; + } + + s.setItem('userId', auth.id.toString()); + s.setItem('userEmail', auth.email); + // TODO icon +} + +function loadAuth(): AuthUser | null { + let s = window.sessionStorage; + let id = s.getItem('userId'); + let email = s.getItem('userEmail'); + + if (id && email) { + return { + id: parseInt(id), + email: email, + icon: + } + } + return null; +} + function useProvideAuth() { - const [user, setUser] = useState(null); + const [user, setUser] = useState(loadAuth()); // TODO: password maybe shouldn't be encoded into the URL. const signin = (email: string, password: string) => { @@ -59,6 +88,7 @@ function useProvideAuth() { icon: , } setUser(user); + persistAuth(user); return user; })(); }; @@ -89,6 +119,7 @@ function useProvideAuth() { throw new Error("Failed to log out."); } setUser(null); + persistAuth(null); })(); }; -- 2.36.1 From f369d4e39029252faa5242025d4f281c98d50463 Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Tue, 17 Nov 2020 16:54:55 +0100 Subject: [PATCH 7/8] Further beautify integrations screen, make secret really secret. --- client/src/api.ts | 8 ++ .../settings/IntegrationSettingsEditor.tsx | 74 ++++++++++++++----- server/endpoints/Integration.ts | 2 + .../20201113155620_add_integrations.ts | 2 + .../test/integration/flows/IntegrationFlow.js | 37 +++++----- server/test/integration/flows/helpers.js | 4 +- 6 files changed, 87 insertions(+), 40 deletions(-) diff --git a/client/src/api.ts b/client/src/api.ts index b4733c1..22bcb68 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -367,7 +367,12 @@ export interface SpotifyClientCredentialsDetails { clientSecret: string, } +export interface SpotifyClientCredentialsSecretDetails { + clientSecret: string, +} + export type IntegrationDetails = SpotifyClientCredentialsDetails; +export type IntegrationSecretDetails = SpotifyClientCredentialsSecretDetails; // Create a new integration (POST). export const CreateIntegrationEndpoint = '/integration'; @@ -375,6 +380,7 @@ export interface CreateIntegrationRequest { name: string, type: IntegrationType, details: IntegrationDetails, + secretDetails: IntegrationSecretDetails, } export interface CreateIntegrationResponse { id: number; @@ -384,6 +390,7 @@ export function checkCreateIntegrationRequest(req: any): boolean { "name" in req.body && "type" in req.body && "details" in req.body && + "secretDetails" in req.body && (req.body.type in IntegrationType); } @@ -393,6 +400,7 @@ export interface ModifyIntegrationRequest { name?: string, type?: IntegrationType, details?: IntegrationDetails, + secretDetails?: IntegrationSecretDetails, } export interface ModifyIntegrationResponse { } export function checkModifyIntegrationRequest(req: any): boolean { diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx index 0210d58..ad58bc1 100644 --- a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx +++ b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx @@ -12,20 +12,24 @@ import { v4 as genUuid } from 'uuid'; import { testSpotify } from '../../../lib/integration/spotify/spotifyClientCreds'; let _ = require('lodash') +interface EditorIntegrationState extends serverApi.IntegrationDetailsResponse { + secretDetails?: any, +} + interface EditIntegrationProps { upstreamId: number | null, - integration: serverApi.IntegrationDetailsResponse, - original: serverApi.IntegrationDetailsResponse, + integration: EditorIntegrationState, + original: EditorIntegrationState, editing: boolean, submitting: boolean, - onChange: (p: serverApi.IntegrationDetailsResponse, editing: boolean) => void, + onChange: (p: EditorIntegrationState, editing: boolean) => void, onSubmit: () => void, onDelete: () => void, } function EditSpotifyClientCredentialsDetails(props: { clientId: string, - clientSecret: string, + clientSecret: string | null, editing: boolean, onChangeClientId: (v: string) => void, onChangeClientSecret: (v: string) => void, @@ -36,7 +40,7 @@ function EditSpotifyClientCredentialsDetails(props: { variant="outlined" disabled={!props.editing} value={props.clientId || ""} - label="Client Id:" + label="Client id" fullWidth onChange={(e: any) => props.onChangeClientId(e.target.value)} /> @@ -45,10 +49,19 @@ function EditSpotifyClientCredentialsDetails(props: { props.onChangeClientSecret(e.target.value)} + onChange={(e: any) => { + props.onChangeClientSecret(e.target.value) + }} + onFocus={(e: any) => { + if(props.clientSecret === null) { + // Change from dots to empty input + console.log("Focus!") + props.onChangeClientSecret(''); + } + }} /> ; @@ -62,9 +75,20 @@ function EditIntegration(props: EditIntegrationProps) { style={{ height: '40px', width: '40px' }} whichStore={ExternalStore.Spotify} /> - Spotify + Spotify (using Client Credentials) } + let IntegrationDescription: Record = { + [serverApi.IntegrationType.SpotifyClientCredentials]: + + This integration allows using the Spotify API to make requests that are + tied to any specific user, such as searching items and retrieving item + metadata.
+ Please see the Spotify API documentation on how to generate a client ID + and client secret. Once set, you will only be able to overwrite the secret + here, not read it. +
+ } return + {IntegrationDescription[props.integration.type]} props.onChange({ @@ -90,7 +115,10 @@ function EditIntegration(props: EditIntegrationProps) { {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && props.onChange({ ...props.integration, @@ -101,8 +129,8 @@ function EditIntegration(props: EditIntegrationProps) { }, props.editing)} onChangeClientSecret={(v: string) => props.onChange({ ...props.integration, - details: { - ...props.integration.details, + secretDetails: { + ...props.integration.secretDetails, clientSecret: v, } }, props.editing)} @@ -157,8 +185,8 @@ export default function IntegrationSettingsEditor(props: {}) { interface EditorState { id: string, //uniquely identifies this editor in the window. upstreamId: number | null, //back-end ID for this integration if any. - integration: serverApi.IntegrationDetailsResponse, - original: serverApi.IntegrationDetailsResponse, + integration: EditorIntegrationState, + original: EditorIntegrationState, editing: boolean, submitting: boolean, } @@ -173,9 +201,12 @@ export default function IntegrationSettingsEditor(props: {}) { }; const submitEditor = (state: EditorState) => { - let integration = state.integration; + let integration: any = state.integration; if (state.upstreamId === null) { + if (!state.integration.secretDetails) { + throw new Error('Cannot create an integration without its secret details set.') + } createIntegration(integration).then((response: any) => { if (!response.id) { throw new Error('failed to submit integration.') @@ -205,9 +236,9 @@ export default function IntegrationSettingsEditor(props: {}) { } const deleteEditor = (state: EditorState) => { - let promise: Promise = state.upstreamId ? + let promise: Promise = state.upstreamId ? deleteIntegration(state.upstreamId) : - (async () => {})(); + (async () => { })(); promise.then((response: any) => { let cpy = _.cloneDeep(editors).filter( @@ -244,7 +275,7 @@ export default function IntegrationSettingsEditor(props: {}) { original={state.original} editing={state.editing} submitting={state.submitting} - onChange={(p: serverApi.IntegrationDetailsResponse, editing: boolean) => { + onChange={(p: EditorIntegrationState, editing: boolean) => { if (!editors) { throw new Error('cannot change editors before loading integrations.') } @@ -262,9 +293,10 @@ export default function IntegrationSettingsEditor(props: {}) { throw new Error('cannot submit editors before loading integrations.') } let cpy: EditorState[] = _.cloneDeep(editors); - cpy.forEach((s: any) => { + cpy.forEach((s: EditorState) => { if (s.id === state.id) { s.submitting = true; + s.integration.secretDetails = undefined; } }) setEditors(cpy); @@ -301,6 +333,8 @@ export default function IntegrationSettingsEditor(props: {}) { type: serverApi.IntegrationType.SpotifyClientCredentials, details: { clientId: '', + }, + secretDetails: { clientSecret: '', }, name: '', diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts index b871ca3..2f33aa5 100644 --- a/server/endpoints/Integration.ts +++ b/server/endpoints/Integration.ts @@ -24,6 +24,7 @@ export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: user: userId, type: reqObject.type, details: JSON.stringify(reqObject.details), + secretDetails: JSON.stringify(reqObject.secretDetails), } const integrationId = (await trx('integrations') .insert(integration) @@ -186,6 +187,7 @@ export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: if ("name" in reqObject) { update["name"] = reqObject.name; } if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); } if ("type" in reqObject) { update["type"] = reqObject.type; } + if ("secretDetails" in reqObject) { update["secretDetails"] = JSON.stringify(reqObject.details); } await trx('integrations') .where({ 'user': userId, 'id': req.params.id }) .update(update) diff --git a/server/migrations/20201113155620_add_integrations.ts b/server/migrations/20201113155620_add_integrations.ts index c560218..08dbc43 100644 --- a/server/migrations/20201113155620_add_integrations.ts +++ b/server/migrations/20201113155620_add_integrations.ts @@ -11,6 +11,8 @@ export async function up(knex: Knex): Promise { table.string('name').notNullable(); // Uniquely identifies this integration configuration for the user. table.string('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify) table.json('details'); // Stores anything that might be needed for the integration to work. + table.json('secretDetails'); // Stores anything that might be needed for the integration to work and which + // should never leave the server. } ) } diff --git a/server/test/integration/flows/IntegrationFlow.js b/server/test/integration/flows/IntegrationFlow.js index 3b17a4c..cd693c0 100644 --- a/server/test/integration/flows/IntegrationFlow.js +++ b/server/test/integration/flows/IntegrationFlow.js @@ -30,10 +30,11 @@ describe('POST /integration with missing or wrong data', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify }, 400); - await helpers.createIntegration(req, { details: {}, type: IntegrationType.spotify }, 400); - await helpers.createIntegration(req, { name: "A", details: {} }, 400); - await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {} }, 400); + await helpers.createIntegration(req, { type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 400); + await helpers.createIntegration(req, { name: "A", details: {}, secretDetails: {} }, 400); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, secretDetails: {} }, 400); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, }, 400); + await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {}, secretDetails: {} }, 400); } finally { req.close(); agent.close(); @@ -47,7 +48,7 @@ describe('POST /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); } finally { req.close(); agent.close(); @@ -61,9 +62,9 @@ describe('PUT /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); - await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationType.spotify, details: { secret: 'cat' } }, 200); - await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.spotify, details: { secret: 'cat' } }) + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' }, secretDetails: {} }, 200); + await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' } }) } finally { req.close(); agent.close(); @@ -77,8 +78,8 @@ describe('PUT /integration with wrong data', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); - await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {} }, 400); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {}, secretDetails: {} }, 400); } finally { req.close(); agent.close(); @@ -92,8 +93,8 @@ describe('DELETE /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); - await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.spotify, details: {} }) + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }) await helpers.deleteIntegration(req, 1, 200); await helpers.checkIntegration(req, 1, 404); } finally { @@ -109,13 +110,13 @@ describe('GET /integration list with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); - await helpers.createIntegration(req, { name: "B", type: IntegrationType.spotify, details: {} }, 200, { id: 2 }); - await helpers.createIntegration(req, { name: "C", type: IntegrationType.spotify, details: {} }, 200, { id: 3 }); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 2 }); + await helpers.createIntegration(req, { name: "C", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 3 }); await helpers.listIntegrations(req, 200, [ - { id: 1, name: "A", type: IntegrationType.spotify, details: {} }, - { id: 2, name: "B", type: IntegrationType.spotify, details: {} }, - { id: 3, name: "C", type: IntegrationType.spotify, details: {} }, + { id: 1, name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }, + { id: 2, name: "B", type: IntegrationType.SpotifyClientCredentials, details: {} }, + { id: 3, name: "C", type: IntegrationType.SpotifyClientCredentials, details: {} }, ]); } finally { req.close(); diff --git a/server/test/integration/flows/helpers.js b/server/test/integration/flows/helpers.js index d905b55..12677cd 100644 --- a/server/test/integration/flows/helpers.js +++ b/server/test/integration/flows/helpers.js @@ -249,7 +249,7 @@ export async function logout( export async function createIntegration( req, - props = { name: "Integration", type: IntegrationType.Spotify, details: {} }, + props = { name: "Integration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, expectStatus = undefined, expectResponse = undefined ) { @@ -266,7 +266,7 @@ export async function createIntegration( export async function modifyIntegration( req, id = 1, - props = { name: "NewIntegration", type: IntegrationType.Spotify, details: {} }, + props = { name: "NewIntegration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, expectStatus = undefined, ) { await req -- 2.36.1 From d8438ec3c14746b583a6a361cd71593ebd8afe22 Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Sat, 21 Nov 2020 14:38:19 +0100 Subject: [PATCH 8/8] Global integrations context which now works together with the edit settings window. --- client/package-lock.json | 8 + client/package.json | 1 + client/src/App.tsx | 5 +- client/src/api.ts | 1 - client/src/components/MainWindow.tsx | 100 ++--- .../components/windows/album/AlbumWindow.tsx | 11 +- .../windows/artist/ArtistWindow.tsx | 8 +- .../windows/manage_tags/ManageTagsWindow.tsx | 7 +- .../windows/settings/IntegrationSettings.tsx | 330 ++++++++++++++++ .../settings/IntegrationSettingsEditor.tsx | 352 ------------------ .../windows/settings/SettingsWindow.tsx | 2 +- client/src/lib/backend/integrations.tsx | 12 +- client/src/lib/backend/queries.tsx | 9 +- client/src/lib/backend/request.tsx | 24 ++ client/src/lib/backend/tags.tsx | 9 +- client/src/lib/integration/Integration.tsx | 59 +++ .../spotify/SpotifyClientCreds.tsx | 95 +++++ .../spotify/spotifyClientCreds.tsx | 14 - .../src/lib/integration/useIntegrations.tsx | 161 ++++++++ client/src/lib/saveChanges.tsx | 9 +- server/endpoints/Integration.ts | 3 + server/integrations/integrations.ts | 5 +- 22 files changed, 785 insertions(+), 440 deletions(-) create mode 100644 client/src/components/windows/settings/IntegrationSettings.tsx delete mode 100644 client/src/components/windows/settings/IntegrationSettingsEditor.tsx create mode 100644 client/src/lib/backend/request.tsx create mode 100644 client/src/lib/integration/Integration.tsx create mode 100644 client/src/lib/integration/spotify/SpotifyClientCreds.tsx delete mode 100644 client/src/lib/integration/spotify/spotifyClientCreds.tsx create mode 100644 client/src/lib/integration/useIntegrations.tsx diff --git a/client/package-lock.json b/client/package-lock.json index b8c35fe..74d3f63 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11286,6 +11286,14 @@ "resolved": "https://registry.npmjs.org/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz", "integrity": "sha1-6RWrjLO5WYdwdfSUNt6/2wQoj+Q=" }, + "react-error-boundary": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.0.2.tgz", + "integrity": "sha512-KVzCusRTFpUYG0OFJbzbdRuxNQOBiGXVCqyNpBXM9z5NFsFLzMjUXMjx8gTja6M6WH+D2PvP3yKz4d8gD1PRaA==", + "requires": { + "@babel/runtime": "^7.11.2" + } + }, "react-error-overlay": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz", diff --git a/client/package.json b/client/package.json index 1fb3ac0..7c82c01 100644 --- a/client/package.json +++ b/client/package.json @@ -24,6 +24,7 @@ "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "^16.13.1", + "react-error-boundary": "^3.0.2", "react-router-dom": "^5.2.0", "react-scripts": "^3.4.3", "typescript": "~3.7.2", diff --git a/client/src/App.tsx b/client/src/App.tsx index 08a6107..45f7fa5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,13 +5,12 @@ import { HTML5Backend } from 'react-dnd-html5-backend'; import MainWindow from './components/MainWindow'; import { ProvideAuth } from './lib/useAuth'; +import { ProvideIntegrations } from './lib/integration/useIntegrations'; function App() { return ( - - - + ); } diff --git a/client/src/api.ts b/client/src/api.ts index 22bcb68..92333f1 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -364,7 +364,6 @@ export enum IntegrationType { export interface SpotifyClientCredentialsDetails { clientId: string, - clientSecret: string, } export interface SpotifyClientCredentialsSecretDetails { diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index 32ae543..ff2110a 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core'; import { grey } from '@material-ui/core/colors'; import AppBar, { AppBarTab } from './appbar/AppBar'; @@ -8,11 +8,13 @@ import AlbumWindow from './windows/album/AlbumWindow'; import TagWindow from './windows/tag/TagWindow'; import SongWindow from './windows/song/SongWindow'; import ManageTagsWindow from './windows/manage_tags/ManageTagsWindow'; -import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom'; +import { BrowserRouter, Switch, Route, Redirect, useHistory } from 'react-router-dom'; import LoginWindow from './windows/login/LoginWindow'; -import { useAuth } from '../lib/useAuth'; +import { useAuth, ProvideAuth } from '../lib/useAuth'; import RegisterWindow from './windows/register/RegisterWindow'; import SettingsWindow from './windows/settings/SettingsWindow'; +import { ErrorBoundary } from 'react-error-boundary'; +import { ProvideIntegrations } from '../lib/integration/useIntegrations'; const darkTheme = createMuiTheme({ palette: { @@ -45,48 +47,52 @@ function PrivateRoute(props: any) { export default function MainWindow(props: any) { return - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } \ No newline at end of file diff --git a/client/src/components/windows/album/AlbumWindow.tsx b/client/src/components/windows/album/AlbumWindow.tsx index 7d14329..28ef06d 100644 --- a/client/src/components/windows/album/AlbumWindow.tsx +++ b/client/src/components/windows/album/AlbumWindow.tsx @@ -12,6 +12,8 @@ import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { queryAlbums, querySongs } from '../../../lib/backend/queries'; import { songGetters } from '../../../lib/songGetters'; import { useParams } from 'react-router'; +import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; +import { useAuth } from '../../../lib/useAuth'; export type AlbumMetadata = serverApi.AlbumDetails; export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest; @@ -55,7 +57,8 @@ export async function getAlbumMetadata(id: number) { }, offset: 0, limit: 1, - }))[0]; + }) + )[0]; } export default function AlbumWindow(props: {}) { @@ -77,6 +80,7 @@ export function AlbumWindowControlled(props: { }) { let { id: albumId, metadata, pendingChanges, songsOnAlbum } = props.state; let { dispatch } = props; + let auth = useAuth(); // Effect to get the album's metadata. useEffect(() => { @@ -87,6 +91,7 @@ export function AlbumWindowControlled(props: { value: m }); }) + .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) }) }, [albumId, dispatch]); // Effect to get the album's songs. @@ -102,7 +107,8 @@ export function AlbumWindowControlled(props: { }, offset: 0, limit: -1, - }); + }) + .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) }); dispatch({ type: AlbumWindowStateActions.SetSongs, value: songs, @@ -153,6 +159,7 @@ export function AlbumWindowControlled(props: { type: AlbumWindowStateActions.Reload }) }) + .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) }) }} /> {applying && } diff --git a/client/src/components/windows/artist/ArtistWindow.tsx b/client/src/components/windows/artist/ArtistWindow.tsx index f9008cc..513a556 100644 --- a/client/src/components/windows/artist/ArtistWindow.tsx +++ b/client/src/components/windows/artist/ArtistWindow.tsx @@ -12,6 +12,8 @@ import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { queryArtists, querySongs } from '../../../lib/backend/queries'; import { songGetters } from '../../../lib/songGetters'; import { useParams } from 'react-router'; +import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; +import { useAuth } from '../../../lib/useAuth'; export type ArtistMetadata = serverApi.ArtistDetails; export type ArtistMetadataChanges = serverApi.ModifyArtistRequest; @@ -82,6 +84,7 @@ export function ArtistWindowControlled(props: { }) { let { metadata, id: artistId, pendingChanges, songsByArtist } = props.state; let { dispatch } = props; + let auth = useAuth(); // Effect to get the artist's metadata. useEffect(() => { @@ -92,6 +95,7 @@ export function ArtistWindowControlled(props: { value: m }); }) + .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) }) }, [artistId, dispatch]); // Effect to get the artist's songs. @@ -107,7 +111,8 @@ export function ArtistWindowControlled(props: { }, offset: 0, limit: -1, - }); + }) + .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) }); dispatch({ type: ArtistWindowStateActions.SetSongs, value: songs, @@ -158,6 +163,7 @@ export function ArtistWindowControlled(props: { type: ArtistWindowStateActions.Reload }) }) + .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) }) }} /> {applying && } diff --git a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx index 9e4dd7e..e6e6c62 100644 --- a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx +++ b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx @@ -11,6 +11,8 @@ import NewTagMenu from './NewTagMenu'; import { v4 as genUuid } from 'uuid'; import Alert from '@material-ui/lab/Alert'; import { useHistory } from 'react-router'; +import { NotLoggedInError, handleNotLoggedIn } from '../../../lib/backend/request'; +import { useAuth } from '../../../lib/useAuth'; var _ = require('lodash'); export interface ManageTagsWindowState extends WindowState { @@ -355,6 +357,7 @@ export function ManageTagsWindowControlled(props: { const [newTagMenuPos, setNewTagMenuPos] = React.useState(null); let { fetchedTags } = props.state; let { dispatch } = props; + let auth = useAuth(); const onOpenNewTagMenu = (e: any) => { setNewTagMenuPos([e.clientX, e.clientY]) @@ -422,7 +425,9 @@ export function ManageTagsWindowControlled(props: { props.dispatch({ type: ManageTagsWindowActions.Reset }); - }).catch((e: Error) => { + }) + .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) }) + .catch((e: Error) => { props.dispatch({ type: ManageTagsWindowActions.SetAlert, value: Failed to save changes: {e.message}, diff --git a/client/src/components/windows/settings/IntegrationSettings.tsx b/client/src/components/windows/settings/IntegrationSettings.tsx new file mode 100644 index 0000000..dfb7ade --- /dev/null +++ b/client/src/components/windows/settings/IntegrationSettings.tsx @@ -0,0 +1,330 @@ +import React, { useState, useEffect } from 'react'; +import { Box, CircularProgress, IconButton, Typography, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions, Dialog, DialogTitle } from '@material-ui/core'; +import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations'; +import AddIcon from '@material-ui/icons/Add'; +import EditIcon from '@material-ui/icons/Edit'; +import CheckIcon from '@material-ui/icons/Check'; +import DeleteIcon from '@material-ui/icons/Delete'; +import ClearIcon from '@material-ui/icons/Clear'; +import * as serverApi from '../../../api'; +import { v4 as genUuid } from 'uuid'; +import { useIntegrations, IntegrationClasses, IntegrationState, isIntegrationState, makeDefaultIntegrationProperties, makeIntegration } from '../../../lib/integration/useIntegrations'; +import Alert from '@material-ui/lab/Alert'; +import Integration from '../../../lib/integration/Integration'; +let _ = require('lodash') + +// This widget is used to either display or edit a few +// specifically needed for Spotify Client credentials integration. +function EditSpotifyClientCredentialsDetails(props: { + clientId: string, + clientSecret: string | null, + editing: boolean, + onChangeClientId: (v: string) => void, + onChangeClientSecret: (v: string) => void, +}) { + return + + props.onChangeClientId(e.target.value)} + /> + + + { + props.onChangeClientSecret(e.target.value) + }} + onFocus={(e: any) => { + if (props.clientSecret === null) { + // Change from dots to empty input + console.log("Focus!") + props.onChangeClientSecret(''); + } + }} + /> + + ; +} + +// An editing widget which is meant to either display or edit properties +// of an integration. +function EditIntegration(props: { + upstreamId?: number, + integration: serverApi.CreateIntegrationRequest, + editing?: boolean, + showSubmitButton?: boolean | "InProgress", + showDeleteButton?: boolean | "InProgress", + showEditButton?: boolean, + showTestButton?: boolean | "InProgress", + showCancelButton?: boolean, + flashMessage?: React.ReactFragment, + isNew: boolean, + onChange?: (p: serverApi.CreateIntegrationRequest) => void, + onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, + onDelete?: () => void, + onEdit?: () => void, + onTest?: () => void, + onCancel?: () => void, +}) { + let IntegrationHeaders: Record = { + [serverApi.IntegrationType.SpotifyClientCredentials]: + + + {IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({ + style: { height: '40px', width: '40px' } + })} + + Spotify (using Client Credentials) + + } + let IntegrationDescription: Record = { + [serverApi.IntegrationType.SpotifyClientCredentials]: + + This integration allows using the Spotify API to make requests that are + tied to any specific user, such as searching items and retrieving item + metadata.
+ Please see the Spotify API documentation on how to generate a client ID + and client secret. Once set, you will only be able to overwrite the secret + here, not read it. +
+ } + + return + + + + {IntegrationDescription[props.integration.type]} + + props.onChange && props.onChange({ + ...props.integration, + name: e.target.value, + })} + /> + + {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && + props.onChange && props.onChange({ + ...props.integration, + details: { + ...props.integration.details, + clientId: v, + } + })} + onChangeClientSecret={(v: string) => props.onChange && props.onChange({ + ...props.integration, + secretDetails: { + ...props.integration.secretDetails, + clientSecret: v, + } + })} + /> + } + {props.flashMessage && props.flashMessage} + + + {props.showEditButton && } + {props.showSubmitButton && props.onSubmit && props.onSubmit(props.integration)} + >} + {props.showDeleteButton && } + {props.showCancelButton && } + {props.showTestButton && } + + +} + +let EditorWithTest = (props: any) => { + const [testFlashMessage, setTestFlashMessage] = + React.useState(undefined); + let { integration, ...rest } = props; + return { + integration.integration.test({}) + .then(() => { + setTestFlashMessage( + Integration is active. + ) + }) + }} + flashMessage={testFlashMessage} + showTestButton={true} + integration={integration.properties} + {...rest} + />; +} + +function AddIntegrationMenu(props: { + position: null | number[], + open: boolean, + onClose?: () => void, + onAdd?: (type: serverApi.IntegrationType) => void, +}) { + const pos = props.open && props.position ? + { left: props.position[0], top: props.position[1] } + : { left: 0, top: 0 } + + return + { + props.onAdd && props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); + props.onClose && props.onClose(); + }} + >Spotify + +} + +function EditIntegrationDialog(props: { + open: boolean, + onClose?: () => void, + upstreamId?: number, + integration: IntegrationState, + onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, + isNew: boolean, +}) { + let [editingIntegration, setEditingIntegration] = + useState(props.integration); + + useEffect(() => { setEditingIntegration(props.integration); }, [props.integration]); + + return + Edit Integration + { + setEditingIntegration({ + ...editingIntegration, + properties: i, + integration: makeIntegration(i, editingIntegration.id), + }); + }} + /> + +} + +export default function IntegrationSettings(props: {}) { + const [addMenuPos, setAddMenuPos] = React.useState(null); + const [editingState, setEditingState] = React.useState(null); + + let { + state: integrations, + addIntegration, + modifyIntegration, + deleteIntegration, + updateFromUpstream, + } = useIntegrations(); + + const onOpenAddMenu = (e: any) => { + setAddMenuPos([e.clientX, e.clientY]) + }; + const onCloseAddMenu = () => { + setAddMenuPos(null); + }; + + return <> + + {integrations === null && } + {Array.isArray(integrations) && + {integrations.map((state: IntegrationState) => + { setEditingState(state); }} + onDelete={() => { + deleteIntegration(state.id) + .then(updateFromUpstream) + }} + /> + )} + + + + } + + { + let p = makeDefaultIntegrationProperties(type); + setEditingState({ + properties: p, + integration: makeIntegration(p, -1), + id: -1, + }) + }} + /> + {editingState && { setEditingState(null); }} + integration={editingState} + isNew={editingState.id === -1} + onSubmit={(v: serverApi.CreateIntegrationRequest) => { + if (editingState.id >= 0) { + const id = editingState.id; + setEditingState(null); + modifyIntegration(id, v) + .then(updateFromUpstream) + } else { + setEditingState(null); + createIntegration({ + ...v, + secretDetails: v.secretDetails || {}, + }) + .then(updateFromUpstream) + } + }} + />} + ; +} \ No newline at end of file diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx deleted file mode 100644 index ad58bc1..0000000 --- a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx +++ /dev/null @@ -1,352 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { useAuth } from '../../../lib/useAuth'; -import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions } from '@material-ui/core'; -import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations'; -import AddIcon from '@material-ui/icons/Add'; -import EditIcon from '@material-ui/icons/Edit'; -import CheckIcon from '@material-ui/icons/Check'; -import DeleteIcon from '@material-ui/icons/Delete'; -import * as serverApi from '../../../api'; -import StoreLinkIcon, { ExternalStore } from '../../common/StoreLinkIcon'; -import { v4 as genUuid } from 'uuid'; -import { testSpotify } from '../../../lib/integration/spotify/spotifyClientCreds'; -let _ = require('lodash') - -interface EditorIntegrationState extends serverApi.IntegrationDetailsResponse { - secretDetails?: any, -} - -interface EditIntegrationProps { - upstreamId: number | null, - integration: EditorIntegrationState, - original: EditorIntegrationState, - editing: boolean, - submitting: boolean, - onChange: (p: EditorIntegrationState, editing: boolean) => void, - onSubmit: () => void, - onDelete: () => void, -} - -function EditSpotifyClientCredentialsDetails(props: { - clientId: string, - clientSecret: string | null, - editing: boolean, - onChangeClientId: (v: string) => void, - onChangeClientSecret: (v: string) => void, -}) { - return - - props.onChangeClientId(e.target.value)} - /> - - - { - props.onChangeClientSecret(e.target.value) - }} - onFocus={(e: any) => { - if(props.clientSecret === null) { - // Change from dots to empty input - console.log("Focus!") - props.onChangeClientSecret(''); - } - }} - /> - - ; -} - -function EditIntegration(props: EditIntegrationProps) { - let IntegrationHeaders: Record = { - [serverApi.IntegrationType.SpotifyClientCredentials]: - - - Spotify (using Client Credentials) - - } - let IntegrationDescription: Record = { - [serverApi.IntegrationType.SpotifyClientCredentials]: - - This integration allows using the Spotify API to make requests that are - tied to any specific user, such as searching items and retrieving item - metadata.
- Please see the Spotify API documentation on how to generate a client ID - and client secret. Once set, you will only be able to overwrite the secret - here, not read it. -
- } - - return - - - - {IntegrationDescription[props.integration.type]} - - props.onChange({ - ...props.integration, - name: e.target.value, - }, props.editing)} - /> - - {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && - props.onChange({ - ...props.integration, - details: { - ...props.integration.details, - clientId: v, - } - }, props.editing)} - onChangeClientSecret={(v: string) => props.onChange({ - ...props.integration, - secretDetails: { - ...props.integration.secretDetails, - clientSecret: v, - } - }, props.editing)} - /> - } - - - {!props.editing && !props.submitting && { props.onChange(props.integration, true); }} - >} - {props.editing && !props.submitting && { props.onSubmit(); }} - >} - {!props.submitting && { props.onDelete(); }} - >} - {!props.submitting && !props.editing && props.upstreamId !== null && } - {props.submitting && } - - -} - -function AddIntegrationMenu(props: { - position: null | number[], - open: boolean, - onClose: () => void, - onAdd: (type: serverApi.IntegrationType) => void, -}) { - const pos = props.open && props.position ? - { left: props.position[0], top: props.position[1] } - : { left: 0, top: 0 } - - return - { - props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); - props.onClose(); - }} - >Spotify - -} - -export default function IntegrationSettingsEditor(props: {}) { - interface EditorState { - id: string, //uniquely identifies this editor in the window. - upstreamId: number | null, //back-end ID for this integration if any. - integration: EditorIntegrationState, - original: EditorIntegrationState, - editing: boolean, - submitting: boolean, - } - let [editors, setEditors] = useState(null); - const [addMenuPos, setAddMenuPos] = React.useState(null); - - const onOpenAddMenu = (e: any) => { - setAddMenuPos([e.clientX, e.clientY]) - }; - const onCloseAddMenu = () => { - setAddMenuPos(null); - }; - - const submitEditor = (state: EditorState) => { - let integration: any = state.integration; - - if (state.upstreamId === null) { - if (!state.integration.secretDetails) { - throw new Error('Cannot create an integration without its secret details set.') - } - createIntegration(integration).then((response: any) => { - if (!response.id) { - throw new Error('failed to submit integration.') - } - let cpy = _.cloneDeep(editors); - cpy.forEach((s: any) => { - if (s.id === state.id) { - s.submitting = false; - s.editing = false; - s.upstreamId = response.id; - } - }) - setEditors(cpy); - }) - } else { - modifyIntegration(state.upstreamId, integration).then(() => { - let cpy = _.cloneDeep(editors); - cpy.forEach((s: any) => { - if (s.id === state.id) { - s.submitting = false; - s.editing = false; - } - }) - setEditors(cpy); - }) - } - } - - const deleteEditor = (state: EditorState) => { - let promise: Promise = state.upstreamId ? - deleteIntegration(state.upstreamId) : - (async () => { })(); - - promise.then((response: any) => { - let cpy = _.cloneDeep(editors).filter( - (e: any) => e.id !== state.id - ); - setEditors(cpy); - }) - } - - useEffect(() => { - getIntegrations() - .then((integrations: serverApi.ListIntegrationsResponse) => { - setEditors(integrations.map((i: any, idx: any) => { - return { - integration: { ...i }, - original: { ...i }, - id: genUuid(), - editing: false, - submitting: false, - upstreamId: i.id, - } - })); - }); - }, []); - - return <> - - {editors === null && } - {editors && - {editors.map((state: EditorState) => - { - if (!editors) { - throw new Error('cannot change editors before loading integrations.') - } - let cpy: EditorState[] = _.cloneDeep(editors); - cpy.forEach((s: any) => { - if (s.id === state.id) { - s.integration = p; - s.editing = editing; - } - }) - setEditors(cpy); - }} - onSubmit={() => { - if (!editors) { - throw new Error('cannot submit editors before loading integrations.') - } - let cpy: EditorState[] = _.cloneDeep(editors); - cpy.forEach((s: EditorState) => { - if (s.id === state.id) { - s.submitting = true; - s.integration.secretDetails = undefined; - } - }) - setEditors(cpy); - submitEditor(state); - }} - onDelete={() => { - if (!editors) { - throw new Error('cannot submit editors before loading integrations.') - } - let cpy: EditorState[] = _.cloneDeep(editors); - cpy.forEach((s: any) => { - if (s.id === state.id) { - s.submitting = true; - } - }) - setEditors(cpy); - deleteEditor(state); - }} - /> - )} - - - - } - - { - let cpy = _.cloneDeep(editors); - cpy.push({ - integration: { - type: serverApi.IntegrationType.SpotifyClientCredentials, - details: { - clientId: '', - }, - secretDetails: { - clientSecret: '', - }, - name: '', - }, - original: null, - id: genUuid(), - editing: true, - submitting: false, - upstreamId: null, - }) - setEditors(cpy); - }} - /> - ; -} \ No newline at end of file diff --git a/client/src/components/windows/settings/SettingsWindow.tsx b/client/src/components/windows/settings/SettingsWindow.tsx index 02f0975..1fe79aa 100644 --- a/client/src/components/windows/settings/SettingsWindow.tsx +++ b/client/src/components/windows/settings/SettingsWindow.tsx @@ -5,7 +5,7 @@ import { useHistory } from 'react-router'; import { useAuth, Auth } from '../../../lib/useAuth'; import Alert from '@material-ui/lab/Alert'; import { Link } from 'react-router-dom'; -import IntegrationSettingsEditor from './IntegrationSettingsEditor'; +import IntegrationSettingsEditor from './IntegrationSettings'; export enum SettingsTab { Integrations = 0, diff --git a/client/src/lib/backend/integrations.tsx b/client/src/lib/backend/integrations.tsx index 04213fe..f4468ff 100644 --- a/client/src/lib/backend/integrations.tsx +++ b/client/src/lib/backend/integrations.tsx @@ -1,4 +1,6 @@ import * as serverApi from '../../api'; +import { useAuth } from '../useAuth'; +import backendRequest from './request'; export async function createIntegration(details: serverApi.CreateIntegrationRequest) { const requestOpts = { @@ -7,10 +9,11 @@ export async function createIntegration(details: serverApi.CreateIntegrationRequ body: JSON.stringify(details), }; - const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts) if (!response.ok) { throw new Error("Response to integration creation not OK: " + JSON.stringify(response)); } + return await response.json(); } @@ -21,10 +24,11 @@ export async function modifyIntegration(id: number, details: serverApi.ModifyInt body: JSON.stringify(details), }; - const response = await fetch( + const response = await backendRequest( (process.env.REACT_APP_BACKEND || "") + serverApi.ModifyIntegrationEndpoint.replace(':id', id.toString()), requestOpts ); + if (!response.ok) { throw new Error("Response to integration modification not OK: " + JSON.stringify(response)); } @@ -35,7 +39,7 @@ export async function deleteIntegration(id: number) { method: 'DELETE', }; - const response = await fetch( + const response = await backendRequest( (process.env.REACT_APP_BACKEND || "") + serverApi.DeleteIntegrationEndpoint.replace(':id', id.toString()), requestOpts ); @@ -49,7 +53,7 @@ export async function getIntegrations() { method: 'GET', }; - const response = await fetch( + const response = await backendRequest( (process.env.REACT_APP_BACKEND || "") + serverApi.ListIntegrationsEndpoint, requestOpts ); diff --git a/client/src/lib/backend/queries.tsx b/client/src/lib/backend/queries.tsx index d93ef40..d6c1aa9 100644 --- a/client/src/lib/backend/queries.tsx +++ b/client/src/lib/backend/queries.tsx @@ -1,5 +1,6 @@ import * as serverApi from '../../api'; import { QueryElem, toApiQuery } from '../query/Query'; +import backendRequest from './request'; export interface QueryArgs { query?: QueryElem, @@ -29,7 +30,7 @@ export async function queryArtists(args: QueryArgs) { }; return (async () => { - const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) let json: any = await response.json(); return json.artists; })(); @@ -57,7 +58,7 @@ export async function queryAlbums(args: QueryArgs) { }; return (async () => { - const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) let json: any = await response.json(); return json.albums; })(); @@ -85,7 +86,7 @@ export async function querySongs(args: QueryArgs) { }; return (async () => { - const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) let json: any = await response.json(); return json.songs; })(); @@ -113,7 +114,7 @@ export async function queryTags(args: QueryArgs) { }; return (async () => { - const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts); let json: any = await response.json(); const tags = json.tags; diff --git a/client/src/lib/backend/request.tsx b/client/src/lib/backend/request.tsx new file mode 100644 index 0000000..b5fa5f5 --- /dev/null +++ b/client/src/lib/backend/request.tsx @@ -0,0 +1,24 @@ +import { ResponsiveFontSizesOptions } from "@material-ui/core/styles/responsiveFontSizes"; +import { useHistory } from "react-router"; +import { Auth } from "../useAuth"; + +export class NotLoggedInError extends Error { + constructor(message: string) { + super(message); + this.name = "NotLoggedInError"; + } +} + +export default async function backendRequest(url: any, ...restArgs: any[]): Promise { + let response = await fetch(url, ...restArgs); + if (response.status === 401 && (await response.json()).reason === "NotLoggedIn") { + console.log("Not logged in!") + throw new NotLoggedInError("Not logged in."); + } + return response; +} + +export function handleNotLoggedIn(auth: Auth, e: NotLoggedInError) { + console.log("Not logged in!") + auth.signout(); +} \ No newline at end of file diff --git a/client/src/lib/backend/tags.tsx b/client/src/lib/backend/tags.tsx index 67146e9..0c3ea72 100644 --- a/client/src/lib/backend/tags.tsx +++ b/client/src/lib/backend/tags.tsx @@ -1,4 +1,5 @@ import * as serverApi from '../../api'; +import backendRequest from './request'; export async function createTag(details: serverApi.CreateTagRequest) { const requestOpts = { @@ -7,7 +8,7 @@ export async function createTag(details: serverApi.CreateTagRequest) { body: JSON.stringify(details), }; - const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts) if (!response.ok) { throw new Error("Response to tag creation not OK: " + JSON.stringify(response)); } @@ -21,7 +22,7 @@ export async function modifyTag(id: number, details: serverApi.ModifyTagRequest) body: JSON.stringify(details), }; - const response = await fetch( + const response = await backendRequest( (process.env.REACT_APP_BACKEND || "") + serverApi.ModifyTagEndpoint.replace(':id', id.toString()), requestOpts ); @@ -35,7 +36,7 @@ export async function deleteTag(id: number) { method: 'DELETE', }; - const response = await fetch( + const response = await backendRequest( (process.env.REACT_APP_BACKEND || "") + serverApi.DeleteTagEndpoint.replace(':id', id.toString()), requestOpts ); @@ -49,7 +50,7 @@ export async function mergeTag(fromId: number, toId: number) { method: 'POST', }; - const response = await fetch( + const response = await backendRequest( (process.env.REACT_APP_BACKEND || "") + serverApi.MergeTagEndpoint .replace(':id', fromId.toString()) .replace(':toId', toId.toString()), diff --git a/client/src/lib/integration/Integration.tsx b/client/src/lib/integration/Integration.tsx new file mode 100644 index 0000000..441262f --- /dev/null +++ b/client/src/lib/integration/Integration.tsx @@ -0,0 +1,59 @@ +import React, { ReactFragment } from 'react'; + +export interface IntegrationAlbum { + name?: string, + artist?: IntegrationArtist, + url?: string, // An URL to access the item externally. +} + +export interface IntegrationArtist { + name?: string, + url?: string, // An URL to access the item externally. +} + +export interface IntegrationSong { + title?: string, + album?: IntegrationAlbum, + artist?: IntegrationArtist, + url?: string, // An URL to access the item externally. +} + +export enum IntegrationFeature { + // Used to test whether the integration is active. + Test = 0, + + // Used to get a bucket of songs (typically: the whole library) + GetSongs, + + // Used to search items and get some amount of candidate results. + SearchSong, + SearchAlbum, + SearchArtist, +} + +export interface IntegrationDescriptor { + supports: IntegrationFeature[], +} + +export default class Integration { + constructor(integrationId: number) { } + + // Common + static getFeatures(): IntegrationFeature[] { return []; } + static getIcon(props: any): ReactFragment { return <> } + + // Requires feature: Test + async test(testParams: any): Promise {} + + // Requires feature: GetSongs + async getSongs(getSongsParams: any): Promise { return []; } + + // Requires feature: SearchSongs + async searchSong(songProps: IntegrationSong): Promise { return []; } + + // Requires feature: SearchAlbum + async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; } + + // Requires feature: SearchArtist + async searchArtist(artistProps: IntegrationArtist): Promise { return []; } +} \ No newline at end of file diff --git a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx new file mode 100644 index 0000000..d211907 --- /dev/null +++ b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationSong } from '../Integration'; +import StoreLinkIcon, { ExternalStore } from '../../../components/common/StoreLinkIcon'; + +enum SearchType { + Song = 'song', + Artist = 'artist', + Album = 'album', +}; + +export default class SpotifyClientCreds extends Integration { + integrationId: number; + + constructor(integrationId: number) { + super(integrationId); + this.integrationId = integrationId; + } + + static getFeatures(): IntegrationFeature[] { + return [ + IntegrationFeature.Test, + IntegrationFeature.SearchSong, + IntegrationFeature.SearchAlbum, + IntegrationFeature.SearchArtist, + ] + } + + static getIcon(props: any) { + return + } + + async test(testParams: {}) { + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + + `/integrations/${this.integrationId}/v1/search?q=queens&type=artist`); + + if (!response.ok) { + throw new Error("Spttify Client Credentails test failed: " + JSON.stringify(response)); + } + } + + async searchSong(songProps: IntegrationSong): Promise { return []; } + async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; } + async searchArtist(artistProps: IntegrationArtist): Promise { return []; } + + async search(query: string, type: SearchType): + Promise { + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + + `/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}`); + + if (!response.ok) { + throw new Error("Spotify Client Credentails search failed: " + JSON.stringify(response)); + } + + switch(type) { + case SearchType.Song: { + return (await response.json()).tracks.items.map((r: any): IntegrationSong => { + return { + title: r.name, + url: r.external_urls.spotify, + artist: { + name: r.artists[0].name, + url: r.artists[0].external_urls.spotify, + }, + album: { + name: r.albums[0].name, + url: r.albums[0].external_urls.spotify, + } + } + }) + } + case SearchType.Artist: { + return (await response.json()).artists.items.map((r: any): IntegrationArtist => { + return { + name: r.name, + url: r.external_urls.spotify, + } + }) + } + case SearchType.Album: { + return (await response.json()).albums.items.map((r: any): IntegrationAlbum => { + return { + name: r.name, + url: r.external_urls.spotify, + artist: { + name: r.artists[0].name, + url: r.artists[0].external_urls.spotify, + }, + } + }) + } + } + } +} \ No newline at end of file diff --git a/client/src/lib/integration/spotify/spotifyClientCreds.tsx b/client/src/lib/integration/spotify/spotifyClientCreds.tsx deleted file mode 100644 index 82f88a1..0000000 --- a/client/src/lib/integration/spotify/spotifyClientCreds.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export async function testSpotify(integrationId: number) { - const requestOpts = { - method: 'GET', - }; - - const response = await fetch( - (process.env.REACT_APP_BACKEND || "") + `/integrations/${integrationId}/v1/search?q=queens&type=artist`, - requestOpts - ); - if (!response.ok) { - throw new Error("Response to tag merge not OK: " + JSON.stringify(response)); - } - console.log("Spotify response: ", response); -} \ No newline at end of file diff --git a/client/src/lib/integration/useIntegrations.tsx b/client/src/lib/integration/useIntegrations.tsx new file mode 100644 index 0000000..883b772 --- /dev/null +++ b/client/src/lib/integration/useIntegrations.tsx @@ -0,0 +1,161 @@ +import React, { useState, useContext, createContext, useReducer, useEffect } from "react"; +import Integration from "./Integration"; +import * as serverApi from '../../api'; +import SpotifyClientCreds from "./spotify/SpotifyClientCreds"; +import * as backend from "../backend/integrations"; +import { handleNotLoggedIn, NotLoggedInError } from "../backend/request"; +import { useAuth } from "../useAuth"; + +export type IntegrationState = { + id: number, + integration: Integration, + properties: serverApi.CreateIntegrationRequest, +}; +export type IntegrationsState = IntegrationState[] | "Loading"; + +export function isIntegrationState(v: any) : v is IntegrationState { + return 'id' in v && 'integration' in v && 'properties' in v; +} + +export interface Integrations { + state: IntegrationsState, + addIntegration: (v: serverApi.CreateIntegrationRequest) => Promise, + deleteIntegration: (id: number) => Promise, + modifyIntegration: (id: number, v: serverApi.CreateIntegrationRequest) => Promise, + updateFromUpstream: () => Promise, +}; + +export const IntegrationClasses: Record = { + [serverApi.IntegrationType.SpotifyClientCredentials]: SpotifyClientCreds, +} + +export function makeDefaultIntegrationProperties(type: serverApi.IntegrationType): + serverApi.CreateIntegrationRequest { + switch(type) { + case serverApi.IntegrationType.SpotifyClientCredentials: { + return { + name: "Spotify", + type: type, + details: { clientId: "" }, + secretDetails: { clientSecret: "" }, + } + } + default: { + throw new Error("Unimplemented default integration.") + } + } +} + +export function makeIntegration(p: serverApi.CreateIntegrationRequest, id: number) { + switch(p.type) { + case serverApi.IntegrationType.SpotifyClientCredentials: { + return new SpotifyClientCreds(id); + } + default: { + throw new Error("Unimplemented integration type.") + } + } +} + +const integrationsContext = createContext({ + state: [], + addIntegration: async () => 0, + modifyIntegration: async () => { }, + deleteIntegration: async () => { }, + updateFromUpstream: async () => { }, +}); + +export function ProvideIntegrations(props: { children: any }) { + const integrations = useProvideIntegrations(); + return {props.children}; +} + +export const useIntegrations = () => { + return useContext(integrationsContext); +}; + +function useProvideIntegrations(): Integrations { + let auth = useAuth(); + enum IntegrationsActions { + SetItem = "SetItem", + Set = "Set", + DeleteItem = "DeleteItem", + AddItem = "AddItem", + } + let IntegrationsReducer = (state: IntegrationsState, action: any): IntegrationsState => { + switch (action.type) { + case IntegrationsActions.SetItem: { + if (state !== "Loading") { + return state.map((item: any) => { + return (item.id === action.id) ? action.value : item; + }) + } + return state; + } + case IntegrationsActions.Set: { + return action.value; + } + case IntegrationsActions.DeleteItem: { + if (state !== "Loading") { + const newState = [...state]; + return newState.filter((item: any) => item.id !== action.id); + } + return state; + } + case IntegrationsActions.AddItem: { + return [...state, action.value]; + } + default: + throw new Error("Unimplemented Integrations state update.") + } + } + + const [state, dispatch] = useReducer(IntegrationsReducer, []) + + let updateFromUpstream = async () => { + backend.getIntegrations() + .then((integrations: serverApi.ListIntegrationsResponse) => { + dispatch({ + type: IntegrationsActions.Set, + value: integrations.map((i: any) => { + return { + integration: new (IntegrationClasses[i.type])(i.id), + properties: { ...i }, + id: i.id, + } + }) + }); + }) + .catch((e: NotLoggedInError) => handleNotLoggedIn(auth, e)); + } + + let addIntegration = async (v: serverApi.CreateIntegrationRequest) => { + const id = await backend.createIntegration(v); + await updateFromUpstream(); + return id; + } + + let deleteIntegration = async (id: number) => { + await backend.deleteIntegration(id); + await updateFromUpstream(); + } + + let modifyIntegration = async (id: number, v: serverApi.CreateIntegrationRequest) => { + await backend.modifyIntegration(id, v); + await updateFromUpstream(); + } + + useEffect(() => { + if (auth.user) { + updateFromUpstream() + } + }, [auth]); + + return { + state: state, + addIntegration: addIntegration, + modifyIntegration: modifyIntegration, + deleteIntegration: deleteIntegration, + updateFromUpstream: updateFromUpstream, + } +} \ No newline at end of file diff --git a/client/src/lib/saveChanges.tsx b/client/src/lib/saveChanges.tsx index db2205f..666c5af 100644 --- a/client/src/lib/saveChanges.tsx +++ b/client/src/lib/saveChanges.tsx @@ -1,4 +1,5 @@ import * as serverApi from '../api'; +import backendRequest from './backend/request'; export async function saveSongChanges(id: number, change: serverApi.ModifySongRequest) { const requestOpts = { @@ -8,7 +9,7 @@ export async function saveSongChanges(id: number, change: serverApi.ModifySongRe }; const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString()); - const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) if(!response.ok) { throw new Error("Failed to save song changes: " + response.statusText); } @@ -22,7 +23,7 @@ export async function saveTagChanges(id: number, change: serverApi.ModifyTagRequ }; const endpoint = serverApi.ModifyTagEndpoint.replace(":id", id.toString()); - const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) if(!response.ok) { throw new Error("Failed to save tag changes: " + response.statusText); } @@ -36,7 +37,7 @@ export async function saveArtistChanges(id: number, change: serverApi.ModifyArti }; const endpoint = serverApi.ModifyArtistEndpoint.replace(":id", id.toString()); - const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) if(!response.ok) { throw new Error("Failed to save artist changes: " + response.statusText); } @@ -50,7 +51,7 @@ export async function saveAlbumChanges(id: number, change: serverApi.ModifyAlbum }; const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString()); - const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) if(!response.ok) { throw new Error("Failed to save album changes: " + response.statusText); } diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts index 2f33aa5..6e32c46 100644 --- a/server/endpoints/Integration.ts +++ b/server/endpoints/Integration.ts @@ -86,6 +86,8 @@ export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex const { id: userId } = req.user; + console.log("List integrations"); + try { const integrations: api.ListIntegrationsResponse = ( await knex.select(['id', 'name', 'type', 'details']) @@ -100,6 +102,7 @@ export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex } }) + console.log("Found integrations:", integrations); await res.send(integrations); } catch (e) { catchUnhandledErrors(e) diff --git a/server/integrations/integrations.ts b/server/integrations/integrations.ts index 1b34416..4b109d6 100644 --- a/server/integrations/integrations.ts +++ b/server/integrations/integrations.ts @@ -57,7 +57,7 @@ export function createIntegrations(knex: Knex) { res.status(400).send({ reason: "An integration ID should be provided in the URL." }); return; } - req._integration = (await knex.select(['id', 'name', 'type', 'details']) + req._integration = (await knex.select(['id', 'name', 'type', 'details', 'secretDetails']) .from('integrations') .where({ 'user': req.user.id, 'id': req._integrationId }))[0]; if (!req._integration) { @@ -66,6 +66,7 @@ export function createIntegrations(knex: Knex) { } req._integration.details = JSON.parse(req._integration.details); + req._integration.secretDetails = JSON.parse(req._integration.secretDetails); switch (req._integration.type) { case IntegrationType.SpotifyClientCredentials: { @@ -73,7 +74,7 @@ export function createIntegrations(knex: Knex) { // FIXME: persist the token req._access_token = await getSpotifyCCAuthToken( req._integration.details.clientId, - req._integration.details.clientSecret, + req._integration.secretDetails.clientSecret, ) if (!req._access_token) { res.status(500).send({ reason: "Unable to get Spotify auth token." }) -- 2.36.1