From 62cdcd3461024198a5374090e45f44d3ee4f30a8 Mon Sep 17 00:00:00 2001 From: Aaron Sachs <898627+asachs01@users.noreply.github.com> Date: Thu, 7 May 2026 14:26:44 -0400 Subject: [PATCH] fix(resources): apply bare-response and pageinate fixes to all resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps the same two HaloPSA quirks across every resource — not just tickets/actions: 1. Single-resource GET endpoints (`GET //{id}`) sometimes return the entity bare and sometimes wrap it in a list-style envelope (`{ entities: [{...}] }`). 26 `.get()` methods were unwrapping unconditionally and would crash with "Cannot read properties of undefined (reading '0')" against any tenant returning the bare shape. 2. HaloPSA silently ignores `page_size`/`page_no` unless `pageinate=true` is sent alongside, so caller-supplied page sizes were capped at the server default of 50 across every list endpoint. Both behaviors are now centralized in `src/resources/utils.ts` (`unwrapSingle`, `addPageinate`, `buildListParams`); resources delegate to the shared helpers so future endpoints inherit the fix automatically. Covers: actions, agents, appointments, assets, clients, contacts, contracts, invoices, items, opportunities, projects, quotes, reference (ticket types, statuses, priorities, categories, SLAs, custom fields, user roles, KB articles, recurring invoices, reports, software licences), sites, suppliers, teams, tickets. --- src/resources/actions.ts | 20 ++------- src/resources/agents.ts | 21 ++++----- src/resources/appointments.ts | 21 ++++----- src/resources/assets.ts | 30 +++++++------ src/resources/clients.ts | 21 ++++----- src/resources/contacts.ts | 21 ++++----- src/resources/contracts.ts | 21 ++++----- src/resources/invoices.ts | 21 ++++----- src/resources/items.ts | 21 ++++----- src/resources/opportunities.ts | 21 ++++----- src/resources/projects.ts | 21 ++++----- src/resources/quotes.ts | 21 ++++----- src/resources/reference.ts | 78 ++++++++++++++++++++++++---------- src/resources/sites.ts | 21 ++++----- src/resources/suppliers.ts | 21 ++++----- src/resources/teams.ts | 21 ++++----- src/resources/tickets.ts | 22 ++-------- src/resources/utils.ts | 56 ++++++++++++++++++++++++ tests/unit/utils.test.ts | 59 +++++++++++++++++++++++++ 19 files changed, 310 insertions(+), 228 deletions(-) create mode 100644 src/resources/utils.ts create mode 100644 tests/unit/utils.test.ts diff --git a/src/resources/actions.ts b/src/resources/actions.ts index 5049399..2dd8667 100644 --- a/src/resources/actions.ts +++ b/src/resources/actions.ts @@ -12,6 +12,7 @@ import type { ActionCreateData, ActionUpdateData, } from '../types/actions.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Actions resource operations @@ -49,10 +50,7 @@ export class ActionsResource { */ async get(id: number): Promise { const response = await this.httpClient.request(`/Actions/${id}`); - const action = - response && typeof response === 'object' && 'actions' in response && Array.isArray(response.actions) - ? response.actions[0] - : (response as Action | undefined); + const action = unwrapSingle(response, 'actions'); if (!action) { throw new Error(`Action ${id} not found`); } @@ -102,18 +100,6 @@ export class ActionsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - if (result.page_size !== undefined || result.page_no !== undefined) { - result.pageinate = true; - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 88efb6a..921663d 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -12,6 +12,7 @@ import type { AgentCreateData, AgentUpdateData, } from '../types/agents.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Agents resource operations @@ -48,11 +49,16 @@ export class AgentsResource { * Get a single agent by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ agents: Agent[] }>(`/Agent/${id}`); - const agent = response.agents[0]; + const response = await this.httpClient.request(`/Agent/${id}`); + + const agent = unwrapSingle(response, 'agents'); + if (!agent) { + throw new Error(`Agent ${id} not found`); + } + return agent; } @@ -111,15 +117,6 @@ export class AgentsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/appointments.ts b/src/resources/appointments.ts index 2fd1472..37e599b 100644 --- a/src/resources/appointments.ts +++ b/src/resources/appointments.ts @@ -12,6 +12,7 @@ import type { AppointmentCreateData, AppointmentUpdateData, } from '../types/appointments.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Appointments resource operations @@ -48,11 +49,16 @@ export class AppointmentsResource { * Get a single appointment by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ appointments: Appointment[] }>(`/Appointment/${id}`); - const appointment = response.appointments[0]; + const response = await this.httpClient.request(`/Appointment/${id}`); + + const appointment = unwrapSingle(response, 'appointments'); + if (!appointment) { + throw new Error(`Appointment ${id} not found`); + } + return appointment; } @@ -99,15 +105,6 @@ export class AppointmentsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/assets.ts b/src/resources/assets.ts index b27866f..00794eb 100644 --- a/src/resources/assets.ts +++ b/src/resources/assets.ts @@ -15,6 +15,7 @@ import type { AssetUpdateData, } from '../types/assets.js'; import type { BaseListParams } from '../types/common.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Assets resource operations @@ -51,11 +52,16 @@ export class AssetsResource { * Get a single asset by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ assets: Asset[] }>(`/Asset/${id}`); - const asset = response.assets[0]; + const response = await this.httpClient.request(`/Asset/${id}`); + + const asset = unwrapSingle(response, 'assets'); + if (!asset) { + throw new Error(`Asset ${id} not found`); + } + return asset; } @@ -102,16 +108,7 @@ export class AssetsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } @@ -138,11 +135,16 @@ export class AssetTypesResource { * Get a single asset type by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ asset_types: AssetType[] }>(`/AssetType/${id}`); - const assetType = response.asset_types[0]; + const response = await this.httpClient.request(`/AssetType/${id}`); + + const assetType = unwrapSingle(response, 'asset_types'); + if (!assetType) { + throw new Error(`Asset type ${id} not found`); + } + return assetType; } diff --git a/src/resources/clients.ts b/src/resources/clients.ts index 4713022..9294286 100644 --- a/src/resources/clients.ts +++ b/src/resources/clients.ts @@ -12,6 +12,7 @@ import type { ClientCreateData, ClientUpdateData, } from '../types/clients.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Clients resource operations @@ -48,11 +49,16 @@ export class ClientsResource { * Get a single client by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ clients: Client[] }>(`/Client/${id}`); - const client = response.clients[0]; + const response = await this.httpClient.request(`/Client/${id}`); + + const client = unwrapSingle(response, 'clients'); + if (!client) { + throw new Error(`Client ${id} not found`); + } + return client; } @@ -99,15 +105,6 @@ export class ClientsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/contacts.ts b/src/resources/contacts.ts index c3086ea..01a7000 100644 --- a/src/resources/contacts.ts +++ b/src/resources/contacts.ts @@ -12,6 +12,7 @@ import type { ContactCreateData, ContactUpdateData, } from '../types/contacts.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Contacts resource operations @@ -48,11 +49,16 @@ export class ContactsResource { * Get a single contact by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ users: Contact[] }>(`/Users/${id}`); - const contact = response.users[0]; + const response = await this.httpClient.request(`/Users/${id}`); + + const contact = unwrapSingle(response, 'users'); + if (!contact) { + throw new Error(`Contact ${id} not found`); + } + return contact; } @@ -99,15 +105,6 @@ export class ContactsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/contracts.ts b/src/resources/contracts.ts index 3445d04..74a81c9 100644 --- a/src/resources/contracts.ts +++ b/src/resources/contracts.ts @@ -12,6 +12,7 @@ import type { ContractCreateData, ContractUpdateData, } from '../types/contracts.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Contracts resource operations @@ -48,11 +49,16 @@ export class ContractsResource { * Get a single contract by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ contracts: Contract[] }>(`/ClientContract/${id}`); - const contract = response.contracts[0]; + const response = await this.httpClient.request(`/ClientContract/${id}`); + + const contract = unwrapSingle(response, 'contracts'); + if (!contract) { + throw new Error(`Contract ${id} not found`); + } + return contract; } @@ -99,15 +105,6 @@ export class ContractsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/invoices.ts b/src/resources/invoices.ts index f092b5b..40a6876 100644 --- a/src/resources/invoices.ts +++ b/src/resources/invoices.ts @@ -12,6 +12,7 @@ import type { InvoiceCreateData, InvoiceUpdateData, } from '../types/invoices.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Invoices resource operations @@ -48,11 +49,16 @@ export class InvoicesResource { * Get a single invoice by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ invoices: Invoice[] }>(`/Invoice/${id}`); - const invoice = response.invoices[0]; + const response = await this.httpClient.request(`/Invoice/${id}`); + + const invoice = unwrapSingle(response, 'invoices'); + if (!invoice) { + throw new Error(`Invoice ${id} not found`); + } + return invoice; } @@ -108,15 +114,6 @@ export class InvoicesResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/items.ts b/src/resources/items.ts index 2372094..b47ec1b 100644 --- a/src/resources/items.ts +++ b/src/resources/items.ts @@ -12,6 +12,7 @@ import type { ItemCreateData, ItemUpdateData, } from '../types/items.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Items resource operations @@ -48,11 +49,16 @@ export class ItemsResource { * Get a single item by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ items: Item[] }>(`/Item/${id}`); - const item = response.items[0]; + const response = await this.httpClient.request(`/Item/${id}`); + + const item = unwrapSingle(response, 'items'); + if (!item) { + throw new Error(`Item ${id} not found`); + } + return item; } @@ -99,15 +105,6 @@ export class ItemsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/opportunities.ts b/src/resources/opportunities.ts index 6183641..499f367 100644 --- a/src/resources/opportunities.ts +++ b/src/resources/opportunities.ts @@ -12,6 +12,7 @@ import type { OpportunityCreateData, OpportunityUpdateData, } from '../types/opportunities.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Opportunities resource operations @@ -48,11 +49,16 @@ export class OpportunitiesResource { * Get a single opportunity by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ opportunities: Opportunity[] }>(`/Opportunities/${id}`); - const opportunity = response.opportunities[0]; + const response = await this.httpClient.request(`/Opportunities/${id}`); + + const opportunity = unwrapSingle(response, 'opportunities'); + if (!opportunity) { + throw new Error(`Opportunity ${id} not found`); + } + return opportunity; } @@ -99,15 +105,6 @@ export class OpportunitiesResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/projects.ts b/src/resources/projects.ts index bd01ddb..a4ec418 100644 --- a/src/resources/projects.ts +++ b/src/resources/projects.ts @@ -14,6 +14,7 @@ import type { ProjectCreateData, ProjectUpdateData, } from '../types/projects.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Projects resource operations @@ -50,11 +51,16 @@ export class ProjectsResource { * Get a single project by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ projects: Project[] }>(`/Projects/${id}`); - const project = response.projects[0]; + const response = await this.httpClient.request(`/Projects/${id}`); + + const project = unwrapSingle(response, 'projects'); + if (!project) { + throw new Error(`Project ${id} not found`); + } + return project; } @@ -110,15 +116,6 @@ export class ProjectsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/quotes.ts b/src/resources/quotes.ts index b7fcf28..8ea914b 100644 --- a/src/resources/quotes.ts +++ b/src/resources/quotes.ts @@ -13,6 +13,7 @@ import type { QuoteUpdateData, QuoteConvertResponse, } from '../types/quotes.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Quotes resource operations @@ -49,11 +50,16 @@ export class QuotesResource { * Get a single quote by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ quotations: Quote[] }>(`/Quotation/${id}`); - const quote = response.quotations[0]; + const response = await this.httpClient.request(`/Quotation/${id}`); + + const quote = unwrapSingle(response, 'quotations'); + if (!quote) { + throw new Error(`Quote ${id} not found`); + } + return quote; } @@ -118,15 +124,6 @@ export class QuotesResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/reference.ts b/src/resources/reference.ts index 4b09c94..47ace27 100644 --- a/src/resources/reference.ts +++ b/src/resources/reference.ts @@ -39,6 +39,7 @@ import type { SoftwareLicenceCreateData, SoftwareLicenceUpdateData, } from '../types/reference.js'; +import { unwrapSingle } from './utils.js'; /** * Convert params to request params format @@ -66,9 +67,12 @@ export class TicketTypesResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ ticket_types: TicketType[] }>(`/TicketType/${id}`); - const ticketType = response.ticket_types[0]; + const response = await this.httpClient.request(`/TicketType/${id}`); + + const ticketType = unwrapSingle(response, 'ticket_types'); + if (!ticketType) throw new Error(`Ticket type ${id} not found`); + return ticketType; } } @@ -84,9 +88,12 @@ export class StatusesResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ statuses: Status[] }>(`/Status/${id}`); - const status = response.statuses[0]; + const response = await this.httpClient.request(`/Status/${id}`); + + const status = unwrapSingle(response, 'statuses'); + if (!status) throw new Error(`Status ${id} not found`); + return status; } } @@ -102,9 +109,12 @@ export class PrioritiesResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ priorities: Priority[] }>(`/Priority/${id}`); - const priority = response.priorities[0]; + const response = await this.httpClient.request(`/Priority/${id}`); + + const priority = unwrapSingle(response, 'priorities'); + if (!priority) throw new Error(`Priority ${id} not found`); + return priority; } } @@ -120,9 +130,12 @@ export class CategoriesResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ categories: Category[] }>(`/Category/${id}`); - const category = response.categories[0]; + const response = await this.httpClient.request(`/Category/${id}`); + + const category = unwrapSingle(response, 'categories'); + if (!category) throw new Error(`Category ${id} not found`); + return category; } } @@ -138,9 +151,12 @@ export class SLAsResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ slas: SLA[] }>(`/SLA/${id}`); - const sla = response.slas[0]; + const response = await this.httpClient.request(`/SLA/${id}`); + + const sla = unwrapSingle(response, 'slas'); + if (!sla) throw new Error(`SLA ${id} not found`); + return sla; } } @@ -156,9 +172,12 @@ export class CustomFieldsResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ fields: CustomFieldDefinition[] }>(`/FieldInfo/${id}`); - const field = response.fields[0]; + const response = await this.httpClient.request(`/FieldInfo/${id}`); + + const field = unwrapSingle(response, 'fields'); + if (!field) throw new Error(`Custom field ${id} not found`); + return field; } } @@ -174,9 +193,12 @@ export class UserRolesResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ roles: UserRole[] }>(`/Role/${id}`); - const role = response.roles[0]; + const response = await this.httpClient.request(`/Role/${id}`); + + const role = unwrapSingle(response, 'roles'); + if (!role) throw new Error(`User role ${id} not found`); + return role; } } @@ -192,9 +214,12 @@ export class KnowledgeBaseResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ articles: KBArticle[] }>(`/KBArticle/${id}`); - const article = response.articles[0]; + const response = await this.httpClient.request(`/KBArticle/${id}`); + + const article = unwrapSingle(response, 'articles'); + if (!article) throw new Error(`KB article ${id} not found`); + return article; } @@ -234,9 +259,12 @@ export class RecurringInvoicesResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ recurring_invoices: RecurringInvoice[] }>(`/RecurringInvoice/${id}`); - const invoice = response.recurring_invoices[0]; + const response = await this.httpClient.request(`/RecurringInvoice/${id}`); + + const invoice = unwrapSingle(response, 'recurring_invoices'); + if (!invoice) throw new Error(`Recurring invoice ${id} not found`); + return invoice; } @@ -276,9 +304,12 @@ export class ReportsResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ reports: Report[] }>(`/Report/${id}`); - const report = response.reports[0]; + const response = await this.httpClient.request(`/Report/${id}`); + + const report = unwrapSingle(response, 'reports'); + if (!report) throw new Error(`Report ${id} not found`); + return report; } @@ -301,9 +332,12 @@ export class SoftwareLicencesResource { } async get(id: number): Promise { - const response = await this.httpClient.request<{ software_licences: SoftwareLicence[] }>(`/SoftwareLicence/${id}`); - const licence = response.software_licences[0]; + const response = await this.httpClient.request(`/SoftwareLicence/${id}`); + + const licence = unwrapSingle(response, 'software_licences'); + if (!licence) throw new Error(`Software licence ${id} not found`); + return licence; } diff --git a/src/resources/sites.ts b/src/resources/sites.ts index ed5d8ef..d9f9599 100644 --- a/src/resources/sites.ts +++ b/src/resources/sites.ts @@ -12,6 +12,7 @@ import type { SiteCreateData, SiteUpdateData, } from '../types/sites.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Sites resource operations @@ -48,11 +49,16 @@ export class SitesResource { * Get a single site by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ sites: Site[] }>(`/Site/${id}`); - const site = response.sites[0]; + const response = await this.httpClient.request(`/Site/${id}`); + + const site = unwrapSingle(response, 'sites'); + if (!site) { + throw new Error(`Site ${id} not found`); + } + return site; } @@ -99,15 +105,6 @@ export class SitesResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/suppliers.ts b/src/resources/suppliers.ts index 6d6669f..3f96ba3 100644 --- a/src/resources/suppliers.ts +++ b/src/resources/suppliers.ts @@ -12,6 +12,7 @@ import type { SupplierCreateData, SupplierUpdateData, } from '../types/suppliers.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Suppliers resource operations @@ -48,11 +49,16 @@ export class SuppliersResource { * Get a single supplier by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ suppliers: Supplier[] }>(`/Supplier/${id}`); - const supplier = response.suppliers[0]; + const response = await this.httpClient.request(`/Supplier/${id}`); + + const supplier = unwrapSingle(response, 'suppliers'); + if (!supplier) { + throw new Error(`Supplier ${id} not found`); + } + return supplier; } @@ -99,15 +105,6 @@ export class SuppliersResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/teams.ts b/src/resources/teams.ts index 51f31f9..98b30ec 100644 --- a/src/resources/teams.ts +++ b/src/resources/teams.ts @@ -12,6 +12,7 @@ import type { TeamCreateData, TeamUpdateData, } from '../types/teams.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Teams resource operations @@ -48,11 +49,16 @@ export class TeamsResource { * Get a single team by ID */ async get(id: number): Promise { - const response = await this.httpClient.request<{ teams: Team[] }>(`/Team/${id}`); - const team = response.teams[0]; + const response = await this.httpClient.request(`/Team/${id}`); + + const team = unwrapSingle(response, 'teams'); + if (!team) { + throw new Error(`Team ${id} not found`); + } + return team; } @@ -99,15 +105,6 @@ export class TeamsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/tickets.ts b/src/resources/tickets.ts index 8ba80fe..d9c6113 100644 --- a/src/resources/tickets.ts +++ b/src/resources/tickets.ts @@ -19,6 +19,7 @@ import type { AttachmentListResponse, AttachmentCreateData, } from '../types/tickets.js'; +import { unwrapSingle, buildListParams as sharedBuildListParams } from './utils.js'; /** * Tickets resource operations @@ -56,10 +57,7 @@ export class TicketsResource { */ async get(id: number): Promise { const response = await this.httpClient.request(`/Tickets/${id}`); - const ticket = - response && typeof response === 'object' && 'tickets' in response && Array.isArray(response.tickets) - ? response.tickets[0] - : (response as Ticket | undefined); + const ticket = unwrapSingle(response, 'tickets'); if (!ticket) { throw new Error(`Ticket ${id} not found`); } @@ -155,20 +153,6 @@ export class TicketsResource { * Build query parameters from list params */ private buildListParams(params?: T): Record { - if (!params) return {}; - - const result: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - // Convert camelCase to snake_case for API - const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[apiKey] = value as string | number | boolean; - } - } - // HaloPSA ignores page_size/page_no unless `pageinate=true` (their typo) is also set. - if (result.page_size !== undefined || result.page_no !== undefined) { - result.pageinate = true; - } - return result; + return sharedBuildListParams(params); } } diff --git a/src/resources/utils.ts b/src/resources/utils.ts new file mode 100644 index 0000000..abcc607 --- /dev/null +++ b/src/resources/utils.ts @@ -0,0 +1,56 @@ +/** + * Internal helpers shared across resource implementations. + */ + +/** + * HaloPSA's `GET //{id}` endpoints sometimes return the entity bare + * (`{ id: 1, ... }`) and sometimes wrap it in a list-style envelope + * (`{ entities: [{...}] }`). The shape is endpoint- and version-dependent. + * This helper accepts either form and returns the entity (or undefined). + */ +export function unwrapSingle( + response: T | Record | undefined | null, + listKey: string +): T | undefined { + if (!response || typeof response !== 'object') { + return undefined; + } + const wrapped = (response as Record)[listKey]; + if (Array.isArray(wrapped)) { + return wrapped[0] as T | undefined; + } + return response as T; +} + +/** + * HaloPSA silently ignores `page_size`/`page_no` unless `pageinate=true` + * (their typo, not ours) is sent alongside. Mutates the params object in + * place when paging is requested. + */ +export function addPageinate( + params: Record +): Record { + if (params.page_size !== undefined || params.page_no !== undefined) { + params.pageinate = true; + } + return params; +} + +/** + * Standard camelCase → snake_case converter used by every resource's + * `buildListParams` to match HaloPSA's API conventions, plus the + * pageinate fix for paging support. + */ +export function buildListParams( + params?: T +): Record { + if (!params) return {}; + const result: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + const apiKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + result[apiKey] = value as string | number | boolean; + } + } + return addPageinate(result); +} diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts new file mode 100644 index 0000000..3c64304 --- /dev/null +++ b/tests/unit/utils.test.ts @@ -0,0 +1,59 @@ +/** + * Resource helper unit tests + */ + +import { describe, it, expect } from 'vitest'; +import { unwrapSingle, addPageinate, buildListParams } from '../../src/resources/utils.js'; + +describe('unwrapSingle', () => { + it('returns first element of wrapped list response', () => { + const wrapped = { tickets: [{ id: 1, summary: 'wrapped' }, { id: 2, summary: 'second' }] }; + expect(unwrapSingle<{ id: number }>(wrapped, 'tickets')).toEqual({ id: 1, summary: 'wrapped' }); + }); + + it('returns response itself when bare object', () => { + const bare = { id: 7, summary: 'bare' }; + expect(unwrapSingle<{ id: number }>(bare, 'tickets')).toEqual(bare); + }); + + it('returns undefined when wrapped list is empty', () => { + expect(unwrapSingle({ tickets: [] }, 'tickets')).toBeUndefined(); + }); + + it('returns undefined for null/undefined response', () => { + expect(unwrapSingle(null, 'tickets')).toBeUndefined(); + expect(unwrapSingle(undefined, 'tickets')).toBeUndefined(); + }); +}); + +describe('addPageinate', () => { + it('adds pageinate=true when page_size is set', () => { + expect(addPageinate({ page_size: 10 })).toEqual({ page_size: 10, pageinate: true }); + }); + + it('adds pageinate=true when page_no is set', () => { + expect(addPageinate({ page_no: 2 })).toEqual({ page_no: 2, pageinate: true }); + }); + + it('does not add pageinate when neither paging param is set', () => { + expect(addPageinate({ open_only: true })).toEqual({ open_only: true }); + }); +}); + +describe('buildListParams', () => { + it('camelCase → snake_case', () => { + expect(buildListParams({ pageSize: 25, openOnly: true })).toEqual({ + page_size: 25, + open_only: true, + pageinate: true, + }); + }); + + it('drops undefined values', () => { + expect(buildListParams({ pageSize: undefined, clientId: 1 })).toEqual({ client_id: 1 }); + }); + + it('returns empty object when params is undefined', () => { + expect(buildListParams()).toEqual({}); + }); +});