diff --git a/.vscode/settings.json b/.vscode/settings.json index fbc87ed..1c1d6cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,6 @@ "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true }, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "explorer.fileNesting.enabled": true } diff --git a/package-lock.json b/package-lock.json index cb91f3b..57049d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4285,12 +4285,13 @@ "dev": true }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { diff --git a/src/agency.ts b/src/agency.ts index 89889c0..c057768 100644 --- a/src/agency.ts +++ b/src/agency.ts @@ -1,5 +1,6 @@ import { Brand, + Cassettes, Domains, Invites, Members, @@ -12,6 +13,7 @@ import type { GetOptions, PostOptions } from './types' export class Agency { readonly brand = new Brand(this) + readonly cassettes = new Cassettes(this) readonly domains = new Domains(this) readonly invites = new Invites(this) readonly members = new Members(this) diff --git a/src/resources/agency/cassettes/cassettes.spec.ts b/src/resources/agency/cassettes/cassettes.spec.ts new file mode 100644 index 0000000..7c79464 --- /dev/null +++ b/src/resources/agency/cassettes/cassettes.spec.ts @@ -0,0 +1,101 @@ +import fetch from 'jest-fetch-mock' +import { Blutui } from '@/blutui' +import { fetchOnce, fetchURL } from '@/utils/testing' + +import cassetteFixture from './fixtures/cassette.json' + +const accessToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' +const blutui = new Blutui(accessToken) + +describe('Cassette', () => { + beforeEach(() => fetch.resetMocks()) + + describe('get', () => { + it('can retireve a cassette', async () => { + fetchOnce(cassetteFixture) + const cassette = await blutui + .agency('foo') + .cassettes.get(cassetteFixture.id) + + expect(fetchURL()).toBe( + `${blutui.baseURL}/v1/agencies/foo/cassettes/${cassetteFixture.id}` + ) + expect(cassette).toMatchObject({ + object: 'cassette', + }) + }) + }) + + describe('create', () => { + it('can create a new cassette', async () => { + fetchOnce(cassetteFixture) + const cassette = await blutui.agency('foo').cassettes.create({ + handle: 'default', + name: 'Default', + project: 'project-id', + }) + + expect(fetchURL()).toBe(`${blutui.baseURL}/v1/agencies/foo/cassettes`) + expect(cassette).toMatchObject({ + object: 'cassette', + name: 'Default', + }) + }) + }) + + describe('update', () => { + it('can update an existing cassette', async () => { + fetchOnce(cassetteFixture) + const cassette = await blutui + .agency('foo') + .cassettes.update(cassetteFixture.id, { + name: 'Default', + }) + + expect(fetchURL()).toBe( + `${blutui.baseURL}/v1/agencies/foo/cassettes/${cassetteFixture.id}` + ) + expect(cassette).toMatchObject({ + object: 'cassette', + name: 'Default', + }) + }) + }) + + describe('remove', () => { + it('can remove a cassette', async () => { + fetchOnce({ id: cassetteFixture.id, object: 'cassette', deleted: true }) + const cassette = await blutui + .agency('foo') + .cassettes.remove(cassetteFixture.id) + + expect(fetchURL()).toBe( + `${blutui.baseURL}/v1/agencies/foo/cassettes/${cassetteFixture.id}` + ) + expect(cassette).toMatchObject({ + object: 'cassette', + deleted: true, + }) + }) + }) + + describe('duplicate', () => { + it('can duplicate an existing cassette', async () => { + fetchOnce(cassetteFixture) + const cassette = await blutui + .agency('foo') + .cassettes.duplicate(cassetteFixture.id, { + name: 'Default', + handle: 'default', + }) + + expect(fetchURL()).toBe( + `${blutui.baseURL}/v1/agencies/foo/cassettes/${cassetteFixture.id}/duplicate` + ) + expect(cassette).toMatchObject({ + object: 'cassette', + }) + }) + }) +}) diff --git a/src/resources/agency/cassettes/cassettes.ts b/src/resources/agency/cassettes/cassettes.ts new file mode 100644 index 0000000..0813f4f --- /dev/null +++ b/src/resources/agency/cassettes/cassettes.ts @@ -0,0 +1,87 @@ +import { + deserializeCassette, + serializeCreateCassetteOptions, + serializeDuplicateCassetteOptions, + serializeUpdateCassetteOptions, +} from './serializers' + +import type { Agency } from '@/agency' +import type { + CassetteResponse, + Cassette, + CreateCassetteOptions, + SerializedCreateCassetteOptions, + UpdateCassetteOptions, + SerializedUpdateCassetteOptions, + DuplicateCassetteOptions, + SerializedDuplicateCassetteOptions, +} from './interfaces' +import type { DeletedResponse, Expandable } from '@/types' + +export class Cassettes { + constructor(private readonly agency: Agency) {} + + /** + * Retrieve a Cassette by ID. + */ + async get(id: string, options?: Expandable<'project'>): Promise { + const { data } = await this.agency.get( + `cassettes/${id}`, + { + query: options, + } + ) + + return deserializeCassette(data) + } + + /** + * Create a new Cassette for a project your agency.. + */ + async create(payload: CreateCassetteOptions): Promise { + const { data } = await this.agency.post< + CassetteResponse, + SerializedCreateCassetteOptions + >('cassettes', serializeCreateCassetteOptions(payload)) + + return deserializeCassette(data) + } + + /** + * Update a Cassette by ID. + */ + async update(id: string, payload: UpdateCassetteOptions): Promise { + const { data } = await this.agency.patch< + CassetteResponse, + SerializedUpdateCassetteOptions + >(`cassettes/${id}`, serializeUpdateCassetteOptions(payload)) + + return deserializeCassette(data) + } + + /** + * Remove a Cassette from a project in your agency. + */ + async remove(id: string): Promise { + const { data } = await this.agency.delete( + `cassettes/${id}` + ) + + return data + } + + /** + * Duplicate a Cassette for a project your agency. + */ + async duplicate( + id: string, + payload: DuplicateCassetteOptions + ): Promise { + const { data } = await this.agency.post< + CassetteResponse, + SerializedDuplicateCassetteOptions + >(`cassettes/${id}/duplicate`, serializeDuplicateCassetteOptions(payload)) + + return deserializeCassette(data) + } +} diff --git a/src/resources/agency/cassettes/fixtures/cassette-list.json b/src/resources/agency/cassettes/fixtures/cassette-list.json new file mode 100644 index 0000000..003ade4 --- /dev/null +++ b/src/resources/agency/cassettes/fixtures/cassette-list.json @@ -0,0 +1,24 @@ +{ + "object": "list", + "data": [ + { + "id": "9d759f5a-7a1f-443e-a466-6471da1d367b", + "object": "cassette", + "handle": "default", + "name": "Default", + "project": "9c17d63b-96c0-4315-b4dd-e55373ce4ffd", + "parent": null, + "created_at": 1711305486, + "updated_at": 1711305486 + } + ], + "meta": { + "hasMore": false, + "currentPage": 1, + "from": 1, + "to": 1, + "perPage": 10, + "total": 1, + "lastPage": 1 + } +} diff --git a/src/resources/agency/cassettes/fixtures/cassette.json b/src/resources/agency/cassettes/fixtures/cassette.json new file mode 100644 index 0000000..7e138ab --- /dev/null +++ b/src/resources/agency/cassettes/fixtures/cassette.json @@ -0,0 +1,10 @@ +{ + "id": "9d759f5a-7a1f-443e-a466-6471da1d367b", + "object": "cassette", + "handle": "default", + "name": "Default", + "project": "9c17d63b-96c0-4315-b4dd-e55373ce4ffd", + "parent": null, + "created_at": 1711305486, + "updated_at": 1711305486 +} diff --git a/src/resources/agency/cassettes/interfaces/cassette.interface.ts b/src/resources/agency/cassettes/interfaces/cassette.interface.ts new file mode 100644 index 0000000..50a4f0a --- /dev/null +++ b/src/resources/agency/cassettes/interfaces/cassette.interface.ts @@ -0,0 +1,23 @@ +import type { Project, ProjectResponse } from '../../projects/interfaces' + +export interface Cassette { + id: string + object: 'cassette' + handle: string + name: string + project: string | Project | null + parent: string | Cassette | null + createdAt: number + updatedAt: number +} + +export interface CassetteResponse { + id: string + object: 'cassette' + handle: string + name: string + project: string | ProjectResponse | null + parent: string | CassetteResponse | null + created_at: number + updated_at: number +} diff --git a/src/resources/agency/cassettes/interfaces/create-cassette-options.interface.ts b/src/resources/agency/cassettes/interfaces/create-cassette-options.interface.ts new file mode 100644 index 0000000..dbe520f --- /dev/null +++ b/src/resources/agency/cassettes/interfaces/create-cassette-options.interface.ts @@ -0,0 +1,11 @@ +export interface CreateCassetteOptions { + name: string + project: string + handle: string +} + +export interface SerializedCreateCassetteOptions { + name: string + project: string + handle: string +} diff --git a/src/resources/agency/cassettes/interfaces/duplicate-cassette-options.interface.ts b/src/resources/agency/cassettes/interfaces/duplicate-cassette-options.interface.ts new file mode 100644 index 0000000..1b5db28 --- /dev/null +++ b/src/resources/agency/cassettes/interfaces/duplicate-cassette-options.interface.ts @@ -0,0 +1,9 @@ +export interface DuplicateCassetteOptions { + handle: string + name: string +} + +export interface SerializedDuplicateCassetteOptions { + handle: string + name: string +} diff --git a/src/resources/agency/cassettes/interfaces/index.ts b/src/resources/agency/cassettes/interfaces/index.ts new file mode 100644 index 0000000..eca4185 --- /dev/null +++ b/src/resources/agency/cassettes/interfaces/index.ts @@ -0,0 +1,4 @@ +export * from './cassette.interface' +export * from './create-cassette-options.interface' +export * from './duplicate-cassette-options.interface' +export * from './update-cassette-options.interface' diff --git a/src/resources/agency/cassettes/interfaces/update-cassette-options.interface.ts b/src/resources/agency/cassettes/interfaces/update-cassette-options.interface.ts new file mode 100644 index 0000000..f7e6be7 --- /dev/null +++ b/src/resources/agency/cassettes/interfaces/update-cassette-options.interface.ts @@ -0,0 +1,7 @@ +export interface UpdateCassetteOptions { + name: string +} + +export interface SerializedUpdateCassetteOptions { + name: string +} diff --git a/src/resources/agency/cassettes/serializers/cassette.serializer.ts b/src/resources/agency/cassettes/serializers/cassette.serializer.ts new file mode 100644 index 0000000..6e612a5 --- /dev/null +++ b/src/resources/agency/cassettes/serializers/cassette.serializer.ts @@ -0,0 +1,30 @@ +import { deserializeProject } from '../../projects/serializers' + +import type { List, ListResponse } from '@/types' +import type { Cassette, CassetteResponse } from '../interfaces' +import { deserializePaginationMeta } from '@/utils/serializers' + +export const deserializeCassette = (cassette: CassetteResponse): Cassette => ({ + id: cassette.id, + object: cassette.object, + handle: cassette.handle, + name: cassette.name, + project: + cassette.project instanceof Object + ? deserializeProject(cassette.project) + : cassette.project, + parent: + cassette.parent instanceof Object + ? deserializeCassette(cassette.parent) + : cassette.parent, + createdAt: cassette.created_at, + updatedAt: cassette.updated_at, +}) + +export const deserializeCassetteList = ( + cassettes: ListResponse +): List => ({ + object: cassettes.object, + data: cassettes.data.map(deserializeCassette), + meta: deserializePaginationMeta(cassettes.meta), +}) diff --git a/src/resources/agency/cassettes/serializers/create-cassette-options.serializer.ts b/src/resources/agency/cassettes/serializers/create-cassette-options.serializer.ts new file mode 100644 index 0000000..a5531c2 --- /dev/null +++ b/src/resources/agency/cassettes/serializers/create-cassette-options.serializer.ts @@ -0,0 +1,12 @@ +import type { + CreateCassetteOptions, + SerializedCreateCassetteOptions, +} from '../interfaces' + +export const serializeCreateCassetteOptions = ( + options: CreateCassetteOptions +): SerializedCreateCassetteOptions => ({ + handle: options.handle, + name: options.name, + project: options.project, +}) diff --git a/src/resources/agency/cassettes/serializers/duplicate-cassette-options.serializer.ts b/src/resources/agency/cassettes/serializers/duplicate-cassette-options.serializer.ts new file mode 100644 index 0000000..88f6f95 --- /dev/null +++ b/src/resources/agency/cassettes/serializers/duplicate-cassette-options.serializer.ts @@ -0,0 +1,11 @@ +import type { + DuplicateCassetteOptions, + SerializedDuplicateCassetteOptions, +} from '../interfaces' + +export const serializeDuplicateCassetteOptions = ( + options: DuplicateCassetteOptions +): SerializedDuplicateCassetteOptions => ({ + handle: options.handle, + name: options.name, +}) diff --git a/src/resources/agency/cassettes/serializers/index.ts b/src/resources/agency/cassettes/serializers/index.ts new file mode 100644 index 0000000..c488482 --- /dev/null +++ b/src/resources/agency/cassettes/serializers/index.ts @@ -0,0 +1,4 @@ +export * from './cassette.serializer' +export * from './create-cassette-options.serializer' +export * from './duplicate-cassette-options.serializer' +export * from './update-cassette-options.serializer' diff --git a/src/resources/agency/cassettes/serializers/update-cassette-options.serializer.ts b/src/resources/agency/cassettes/serializers/update-cassette-options.serializer.ts new file mode 100644 index 0000000..45c8ce5 --- /dev/null +++ b/src/resources/agency/cassettes/serializers/update-cassette-options.serializer.ts @@ -0,0 +1,10 @@ +import type { + SerializedUpdateCassetteOptions, + UpdateCassetteOptions, +} from '../interfaces' + +export const serializeUpdateCassetteOptions = ( + options: UpdateCassetteOptions +): SerializedUpdateCassetteOptions => ({ + name: options.name, +}) diff --git a/src/resources/agency/domains/serializers/domain.serializer.ts b/src/resources/agency/domains/serializers/domain.serializer.ts index 0c671b4..5d344e6 100644 --- a/src/resources/agency/domains/serializers/domain.serializer.ts +++ b/src/resources/agency/domains/serializers/domain.serializer.ts @@ -21,7 +21,7 @@ export const deserializeDomain = (domain: DomainResponse): Domain => ({ export const deserializeDomainList = ( domains: ListResponse ): List => ({ - object: 'list', + object: domains.object, data: domains.data.map(deserializeDomain), meta: deserializePaginationMeta(domains.meta), }) diff --git a/src/resources/agency/index.ts b/src/resources/agency/index.ts index b464cdf..906146f 100644 --- a/src/resources/agency/index.ts +++ b/src/resources/agency/index.ts @@ -1,4 +1,5 @@ export { Brand } from './brand/brand' +export { Cassettes } from './cassettes/cassettes' export { Domains } from './domains/domains' export { Invites } from './invites/invites' export { Members } from './members/members' diff --git a/src/resources/agency/projects/projects.spec.ts b/src/resources/agency/projects/projects.spec.ts index 1228007..062be53 100644 --- a/src/resources/agency/projects/projects.spec.ts +++ b/src/resources/agency/projects/projects.spec.ts @@ -6,6 +6,7 @@ import projectFixture from './fixtures/project.json' import projectWithPrimaryDomainFixture from './fixtures/project-with-primary-domain.json' import projectListFixture from './fixtures/project-list.json' import domainListFixture from '../domains/fixtures/domain-list.json' +import cassetteListFixture from '../cassettes/fixtures/cassette-list.json' const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' @@ -182,6 +183,22 @@ describe('Project', () => { }) }) + describe('cassettes', () => { + it('can retrieve a list of cassettes for a project', async () => { + fetchOnce(cassetteListFixture) + const cassettes = await blutui + .agency('foo') + .projects.cassettes(projectFixture.id) + + expect(fetchURL()).toBe( + `${blutui.baseURL}/v1/agencies/foo/projects/${projectFixture.id}/cassettes` + ) + expect(cassettes).toMatchObject({ + object: 'list', + }) + }) + }) + describe('search', () => { it('can search for projects', async () => { fetchOnce(projectListFixture) diff --git a/src/resources/agency/projects/projects.ts b/src/resources/agency/projects/projects.ts index 767b306..3afbae1 100644 --- a/src/resources/agency/projects/projects.ts +++ b/src/resources/agency/projects/projects.ts @@ -18,6 +18,7 @@ import type { UpdateProjectOptions, } from './interfaces' import type { Domain, DomainResponse } from '../domains/interfaces' +import type { Cassette, CassetteResponse } from '../cassettes/interfaces' import type { DeletedResponse, Expandable, @@ -25,6 +26,7 @@ import type { ListResponse, PaginationOptions, } from '@/types' +import { deserializeCassetteList } from '../cassettes/serializers' export class Projects { constructor(private readonly agency: Agency) {} @@ -130,6 +132,21 @@ export class Projects { return deserializeDomainList(data) } + /** + * Retrieve the Cassettes for a project in your agency. + */ + async cassettes( + id: string, + options?: PaginationOptions + ): Promise> { + const { data } = await this.agency.get>( + `projects/${id}/cassettes`, + { query: options } + ) + + return deserializeCassetteList(data) + } + /** * Search for projects in your agency. */ diff --git a/src/utils/client.ts b/src/utils/client.ts index 0411308..0c77d50 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -22,6 +22,9 @@ export class Client { } } + /** + * Perform a `GET` request to the given path. + */ async get( path: string, options: { params?: Record; headers?: HeadersInit } @@ -32,6 +35,9 @@ export class Client { }) } + /** + * Perform a `POST` request to the given path with the given payload. + */ async post( path: string, entity: Entity, @@ -46,6 +52,9 @@ export class Client { }) } + /** + * Perform a `PATCH` request to the given path with the given payload. + */ async patch( path: string, entity: Entity, @@ -60,6 +69,9 @@ export class Client { }) } + /** + * Perform a `DELETE` request to the given path. + */ async delete( path: string, options: { params?: Record; headers?: HeadersInit } @@ -72,6 +84,9 @@ export class Client { }) } + /** + * Get the resource URL for the given path. + */ private getResourceURL(path: string, params?: Record) { const queryString = getQueryString(params) const url = new URL( @@ -93,6 +108,9 @@ export class Client { return `${version}/${newPath}` } + /** + * Perform a request with the given options. + */ private async fetch(url: string, options?: RequestInit) { const response = await this._fetchFn(url, { ...this.options, @@ -127,6 +145,9 @@ export class Client { } } +/** + * Get the query string. + */ function getQueryString(queryObj?: Record) { if (!queryObj) return undefined @@ -146,6 +167,9 @@ function getQueryString(queryObj?: Record) { return new URLSearchParams(sanitizedQueryObj).toString() } +/** + * Get the body as a JSON string. + */ function getBody(entity: Entity): BodyInit | null | undefined { return JSON.stringify(entity) }