diff --git a/CHANGELOG.md b/CHANGELOG.md index f977b5f..a564abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.0 + +- Add a `Client#getHosts` method for obtaining a list of hosts matching specified `mode` and `public` criteria +- Create `Host`, `GetHostsQueryParams`, `GetHostsPayload` interfaces +- Rename `AuthSuccessBody` interface to `AuthPayload` +- Change `Client#authPersonal` method, so that it takes a single `AuthPersonalCredentials` object argument, instead of three separate `email`, `password` and `tfa` strings + ## 0.1.3 - Fix accidentally publishing a codeless package via Github Actions workflow diff --git a/README.md b/README.md index 334db62..a5e7828 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # node-parsec-sdk +JavaScript and TypeScript SDK for Parsec remote desktop. + ## DISCLAIMER This is an **UNOFFICIAL** package, which is also very early in development. The vast majority of necessary features is missing at this point. See the _Roadmap_ project and issues on Github to get a list of upcoming functionalities. @@ -14,7 +16,7 @@ npm install --save parsec-sdk ## Documentation -Online documentation is generated using [Typedoc](https://typedoc.org/) and hosted on Github Pages. It's available here: [https://maciejpedzich.github.io/node-parsec-sdk/](https://maciejpedzich.github.io/node-parsec-sdk/) +Online documentation is automatically generated using [TypeDoc](https://typedoc.org/) and hosted on Github Pages. It's available on [https://maciejpedzich.github.io/node-parsec-sdk/](https://maciejpedzich.github.io/node-parsec-sdk/) ## Code example @@ -27,13 +29,13 @@ import { Client } from 'parsec-sdk'; const parsec = new Client(); -async function demo() { +async function authDemo() { try { - await parsec.authPersonal( - 'parsec-account-email@example.com', - 'ParsecAccountP4ssword!', - '123456' // OPTIONAL 2FA code - ); + await parsec.authPersonal({ + email: 'parsec-account-email@example.com', + password: 'ParsecAccountP4ssword!', + tfa: '123456' // OPTIONAL TFA code + }); console.log(`Peer ID: ${parsec.peerID}\nSession ID: ${parsec.sessionID}`); } catch (error) { @@ -41,7 +43,18 @@ async function demo() { } } -demo(); +async function hostsDemo() { + try { + const { data } = await parsec.getHosts({ mode: 'desktop' }); + + console.log(data); + } catch (error) { + console.error(error); + } +} + +authDemo(); +hostsDemo(); ``` ## Development diff --git a/package-lock.json b/package-lock.json index b347749..222ccf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parsec-sdk", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parsec-sdk", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "axios": "^0.24.0" diff --git a/package.json b/package.json index bb8dd18..bbc6917 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parsec-sdk", - "version": "0.1.3", + "version": "0.2.0", "description": "UNOFFICIAL Parsec remote desktop SDK for JavaScript", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/classes/Client.ts b/src/classes/Client.ts index b4007cc..ecade46 100644 --- a/src/classes/Client.ts +++ b/src/classes/Client.ts @@ -1,11 +1,19 @@ import { AxiosError } from 'axios'; +import { stringify } from 'querystring'; -import { httpClient } from '../utils/httpClient'; +import { http } from '../utils/http'; import { Status } from '../enums/Status'; + import { InvalidCredentialsError } from '../errors/InvalidCredentials'; import { TFARequiredError } from '../errors/TFARequired'; -import { AuthErrorBody } from '../interfaces/AuthErrorBody'; -import { AuthSuccessBody } from '../interfaces/AuthSuccessBody'; +import { AuthRequiredError } from '../errors/AuthRequired'; + +import { AuthPersonalCredentials } from 'src/interfaces/auth/PersonalCredentials'; +import { AuthErrorBody } from '../interfaces/auth/ErrorBody'; +import { AuthPayload } from '../interfaces/auth/Payload'; + +import { GetHostsPayload } from '../interfaces/host/GetPayload'; +import { GetHostsQueryParams } from '../interfaces/host/GetQueryParams'; export class Client { public status: Status = Status.PARSEC_NOT_RUNNING; @@ -17,30 +25,25 @@ export class Client { /** * Authenticate client using the _personal_ strategy * - * @param {string} email Parsec account's email - * @param {string} password Parsec account's password - * @param {string} [tfa] TFA code + * @param {@link AuthPersonalCredentials} credentials Credentials to use + * in the authentication request */ - public async authPersonal(email: string, password: string, tfa?: string) { + public async authPersonal(credentials: AuthPersonalCredentials) { try { if (this.sessionID && this.peerID && this.status === Status.PARSEC_OK) { return; } this.status = Status.PARSEC_CONNECTING; - - const response = await httpClient.post('/v1/auth/', { - email, - password, - tfa - }); - const { host_peer_id, session_id } = response.data; + const { data } = await http.post('/v1/auth', credentials); + const { host_peer_id, session_id } = data; this.peerID = host_peer_id; this.sessionID = session_id; this.status = Status.PARSEC_OK; } catch (error) { this.status = Status.ERR_DEFAULT; + const httpErrorBody = (error as AxiosError).response?.data; if (httpErrorBody?.tfa_required) { @@ -49,7 +52,41 @@ export class Client { throw new InvalidCredentialsError(); } - throw error; + throw new Error(httpErrorBody?.error || (error as Error).message); + } + } + /** + * Obtain a list of hosts matching specified `mode` and `public` criteria + * + * @param {@link GetHostsQueryParams} queryParams get hosts request + * query params object representation + */ + public async getHosts(queryParams: GetHostsQueryParams) { + try { + const queryString = stringify({ ...queryParams }); + const { data } = await http.get( + `/v2/hosts?${queryString}`, + { + headers: { + Authorization: `Bearer ${this.sessionID}` + } + } + ); + + return data; + } catch (error) { + this.status = Status.ERR_DEFAULT; + + const httpErrorBody = (error as AxiosError).response?.data; + + if ( + !this.sessionID && + httpErrorBody?.error === 'no session ID in request header' + ) { + throw new AuthRequiredError(); + } + + throw new Error(httpErrorBody?.error || (error as Error).message); } } } diff --git a/src/errors/AuthRequired.ts b/src/errors/AuthRequired.ts new file mode 100644 index 0000000..ac06b89 --- /dev/null +++ b/src/errors/AuthRequired.ts @@ -0,0 +1,5 @@ +export class AuthRequiredError extends Error { + constructor() { + super('Authentication is required to proceed'); + } +} diff --git a/src/index.ts b/src/index.ts index aea45ca..434b4c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,21 @@ import { Client } from './classes/Client'; import { Status } from './enums/Status'; +import { AuthPersonalCredentials } from './interfaces/auth/PersonalCredentials'; +import { Host } from './interfaces/host/Host'; +import { GetHostsQueryParams } from './interfaces/host/GetQueryParams'; +import { GetHostsPayload } from './interfaces/host/GetPayload'; + import { InvalidCredentialsError } from './errors/InvalidCredentials'; import { TFARequiredError } from './errors/TFARequired'; -export { Client, Status, InvalidCredentialsError, TFARequiredError }; +export { + Client, + Status, + AuthPersonalCredentials, + Host, + GetHostsQueryParams, + GetHostsPayload, + InvalidCredentialsError, + TFARequiredError +}; diff --git a/src/interfaces/AuthErrorBody.ts b/src/interfaces/auth/ErrorBody.ts similarity index 100% rename from src/interfaces/AuthErrorBody.ts rename to src/interfaces/auth/ErrorBody.ts diff --git a/src/interfaces/AuthSuccessBody.ts b/src/interfaces/auth/Payload.ts similarity index 75% rename from src/interfaces/AuthSuccessBody.ts rename to src/interfaces/auth/Payload.ts index 9a3dbd6..6a30610 100644 --- a/src/interfaces/AuthSuccessBody.ts +++ b/src/interfaces/auth/Payload.ts @@ -1,5 +1,5 @@ /** @internal */ -export interface AuthSuccessBody { +export interface AuthPayload { instance_id: string; user_id: number; session_id: string; diff --git a/src/interfaces/auth/PersonalCredentials.ts b/src/interfaces/auth/PersonalCredentials.ts new file mode 100644 index 0000000..2e7c45a --- /dev/null +++ b/src/interfaces/auth/PersonalCredentials.ts @@ -0,0 +1,13 @@ +/** + * Representation of the _personal_ authentication credentials/request body + */ +export interface AuthPersonalCredentials { + /** Parsec account's email address */ + email: string; + + /** Parsec account's password */ + password: string; + + /** Optional TFA code */ + tfa?: string; +} diff --git a/src/interfaces/host/GetPayload.ts b/src/interfaces/host/GetPayload.ts new file mode 100644 index 0000000..bc373b5 --- /dev/null +++ b/src/interfaces/host/GetPayload.ts @@ -0,0 +1,7 @@ +import { Host } from './Host'; + +/** Payload of the `getHosts` call */ +export interface GetHostsPayload { + data: Host[]; + has_more: boolean; +} diff --git a/src/interfaces/host/GetQueryParams.ts b/src/interfaces/host/GetQueryParams.ts new file mode 100644 index 0000000..e6d43cb --- /dev/null +++ b/src/interfaces/host/GetQueryParams.ts @@ -0,0 +1,10 @@ +/** + * Object representation of `getHosts` request query params + */ +export interface GetHostsQueryParams { + /** Host's mode */ + mode: 'desktop' | 'game'; + + /** Host's visibility */ + public?: boolean; +} diff --git a/src/interfaces/host/Host.ts b/src/interfaces/host/Host.ts new file mode 100644 index 0000000..73485f9 --- /dev/null +++ b/src/interfaces/host/Host.ts @@ -0,0 +1,42 @@ +/** + * Host object representation + */ +export interface Host { + /** Host computer's peer ID */ + peer_id: string; + + /** User that created the host */ + user: { + id: number; + name: string; + warp: boolean; + }; + + /** Internal Parsec game ID */ + game_id: string; + + /** Parsec build number */ + build: string; + + /** Host's description */ + description: string; + + /** Maximal number of players allowed to be connected simultaneously */ + max_players: number; + + /** Host's mode, can be either _desktop_ or _game_ */ + mode: 'desktop' | 'game'; + + /** Host's name */ + name: string; + + /** Number of players currently connected to the host */ + players: number; + + /** Host's visibility */ + public: boolean; + + /** Determines if the host that made the `GET /hosts` call + * is attached to the same sessionID */ + self: boolean; +} diff --git a/src/utils/httpClient.ts b/src/utils/http.ts similarity index 70% rename from src/utils/httpClient.ts rename to src/utils/http.ts index cb0e9b0..6987bbf 100644 --- a/src/utils/httpClient.ts +++ b/src/utils/http.ts @@ -1,6 +1,6 @@ import axios from 'axios'; /** @internal */ -export const httpClient = axios.create({ +export const http = axios.create({ baseURL: 'https://kessel-api.parsecgaming.com' }); diff --git a/tests/client/authPersonal.spec.ts b/tests/client/authPersonal.spec.ts index 2c3e6a1..5bc9fb1 100644 --- a/tests/client/authPersonal.spec.ts +++ b/tests/client/authPersonal.spec.ts @@ -1,5 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; -import { httpClient } from '../../src/utils/httpClient'; +import { http } from '../../src/utils/http'; import { Client } from '../../src/classes/Client'; import { Status } from '../../src/enums/Status'; @@ -8,17 +8,19 @@ import { InvalidCredentialsError } from '../../src/errors/InvalidCredentials'; import { TFARequiredError } from '../../src/errors/TFARequired'; const client = new Client(); -const mockAxios = new MockAdapter(httpClient); +const mockAxios = new MockAdapter(http); mockAxios - .onPost('/v1/auth/') + .onPost('/v1/auth') .replyOnce(403, { error: 'Your email/password combination is incorrect.' }) - .onPost('/v1/auth/') + .onPost('/v1/auth') .replyOnce(403, { error: 'You need to provide an authentication code.', tfa_required: true }) - .onPost('/v1/auth/') + .onPost('/v1/auth') + .replyOnce(404) + .onPost('/v1/auth') .replyOnce(201, { host_peer_id: '123abc', session_id: 'xyz456' @@ -27,7 +29,10 @@ mockAxios describe("Parsec client's authPersonal method", () => { it('should throw InvalidCredentialsError and set error status if called with wrong email/password', async () => { try { - await client.authPersonal('invalid@example.com', 'Invalid_passw0rd'); + await client.authPersonal({ + email: 'invalid@example.com', + password: 'Invalid_passw0rd' + }); } catch (error) { expect(error).toBeInstanceOf(InvalidCredentialsError); expect(client.status).toEqual(Status.ERR_DEFAULT); @@ -36,20 +41,38 @@ describe("Parsec client's authPersonal method", () => { it('should throw TFARequiredError and set error status if called with valid credentials, but no TFA code', async () => { try { - await client.authPersonal('john.doe@example.com', 'Password123!'); + await client.authPersonal({ + email: 'john.doe@example.com', + password: 'Password123' + }); } catch (error) { expect(error).toBeInstanceOf(TFARequiredError); expect(client.status).toEqual(Status.ERR_DEFAULT); } }); + it('should throw any other error caught during execution', async () => { + try { + // Throws an error because of the 404 status code of the 3rd reply + await client.authPersonal({ + email: 'john.doe@example.com', + password: 'Password123', + tfa: '852005' + }); + } catch (error) { + expect(error).not.toBeInstanceOf(InvalidCredentialsError); + expect(error).not.toBeInstanceOf(TFARequiredError); + expect(client.status).toEqual(Status.ERR_DEFAULT); + } + }); + it('should set peer and host IDs and OK status if called with valid credentials and TFA code', async () => { try { - await client.authPersonal( - 'john.doe@example.com', - 'Password123!', - '069420' - ); + await client.authPersonal({ + email: 'john.doe@example.com', + password: 'Password123', + tfa: '852005' + }); expect(client.peerID).not.toBeUndefined(); expect(client.sessionID).not.toBeUndefined(); @@ -65,11 +88,11 @@ describe("Parsec client's authPersonal method", () => { const sessionIDBeforeAuthCall = client.sessionID; const statusBeforeAuthCall = client.status; - await client.authPersonal( - 'john.doe@example.com', - 'Password123!', - '069420' - ); + await client.authPersonal({ + email: 'john.doe@example.com', + password: 'Password123', + tfa: '852005' + }); expect(client.peerID).toEqual(peerIDBeforeAuthCall); expect(client.sessionID).toEqual(sessionIDBeforeAuthCall); @@ -78,24 +101,4 @@ describe("Parsec client's authPersonal method", () => { console.error(error); } }); - - it('should throw any other error caught during execution', async () => { - try { - // Reset client's data and status - client.peerID = undefined; - client.sessionID = undefined; - client.status = Status.PARSEC_NOT_RUNNING; - - // Throws an error because we haven't defined 4th mockAxios response - await client.authPersonal( - 'john.doe@example.com', - 'Password123!', - '069420' - ); - } catch (error) { - expect(error).not.toBeInstanceOf(InvalidCredentialsError); - expect(error).not.toBeInstanceOf(TFARequiredError); - expect(client.status).toEqual(Status.ERR_DEFAULT); - } - }); }); diff --git a/tests/client/getHosts.spec.ts b/tests/client/getHosts.spec.ts new file mode 100644 index 0000000..dd6c273 --- /dev/null +++ b/tests/client/getHosts.spec.ts @@ -0,0 +1,75 @@ +import MockAdapter from 'axios-mock-adapter'; +import { http } from '../../src/utils/http'; + +import { Client } from '../../src/classes/Client'; +import { Status } from '../../src/enums/Status'; +import { AuthRequiredError } from '../../src/errors/AuthRequired'; + +const client = new Client(); +const mockAxios = new MockAdapter(http); +const hostsEndpoint = new RegExp('/v2/hosts'); + +mockAxios + .onGet(hostsEndpoint) + .replyOnce(401, { + error: 'no session ID in request header' + }) + .onGet(hostsEndpoint) + .replyOnce(400) + .onGet(hostsEndpoint) + .replyOnce(200, { + data: [ + { + peer_id: '2a0045', + name: 'NICE-HOST', + mode: 'desktop', + public: false + } + ], + has_more: false + }); + +describe("Parsec client's getHosts method", () => { + it('should throw AuthRequiredError if called without authenticating first', async () => { + try { + await client.getHosts({ mode: 'desktop' }); + } catch (error) { + expect(error).toBeInstanceOf(AuthRequiredError); + expect(client.status).toEqual(Status.ERR_DEFAULT); + } + }); + + it('should throw a generic error if authenticated but invalid mode query param was specified', async () => { + try { + client.sessionID = 'ParsecIsAwesome'; + await client.getHosts({ mode: 'squid' as 'game' }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(client.status).toEqual(Status.ERR_DEFAULT); + } + }); + + it('should return HTTP response body if authenticated and called with valid params', async () => { + try { + client.sessionID = 'SrslyParsecSlaps'; + const getHostsReturn = await client.getHosts({ + mode: 'desktop', + public: false + }); + + expect(getHostsReturn.has_more).toEqual(false); + expect(getHostsReturn.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + peer_id: '2a0045', + name: 'NICE-HOST', + mode: 'desktop', + public: false + }) + ]) + ); + } catch (error) { + console.error(error); + } + }); +});