GeezerVison is showing github.com — refactor(servicenow): type the shared response boundary instead of any · simstudioai/sim@bb41f42
File treeExpand file treeCollapse file tree Open diff view settings apps/sim/tools/servicenow
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/servicenow/get_change_next_states.ts +6-3Lines changed: 6 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 8 | 8 | buildServiceNowHeaders, | 9 | 9 | normalizeInstanceUrl, | 10 | 10 | parseServiceNowResponse, | | 11 | + toRecordObject, | 11 | 12 | } from '@/tools/servicenow/utils' | 12 | 13 | import type { ToolConfig } from '@/tools/types' | 13 | 14 |
| @@ -47,13 +48,15 @@ export const getChangeNextStatesTool: ToolConfig< | 47 | 48 |
| 48 | 49 | transformResponse: async (response: Response) => { | 49 | 50 | const data = await parseServiceNowResponse(response) | 50 | | - const result = data.result ?? {} | | 51 | + const result = toRecordObject(data.result) | 51 | 52 |
| 52 | 53 | const availableStates: string[] = Array.isArray(result.available_states) | 53 | 54 | ? result.available_states.map((state: unknown) => String(state)) | 54 | 55 | : [] | 55 | | - const stateLabels: Record<string, string> = | 56 | | - result.state_label && typeof result.state_label === 'object' ? result.state_label : {} | | 56 | + const stateLabels: Record<string, string> = {} | | 57 | + for (const [state, label] of Object.entries(toRecordObject(result.state_label))) { | | 58 | + if (typeof label === 'string') stateLabels[state] = label | | 59 | + } | 57 | 60 |
| 58 | 61 | /** | 59 | 62 | * ServiceNow groups `state_transitions` by target state, so it arrives as an | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/servicenow/get_ci.ts +2-1Lines changed: 2 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 4 | 4 | buildServiceNowHeaders, | 5 | 5 | normalizeInstanceUrl, | 6 | 6 | parseServiceNowResponse, | | 7 | + toRecordObject, | 7 | 8 | } from '@/tools/servicenow/utils' | 8 | 9 | import type { ToolConfig } from '@/tools/types' | 9 | 10 |
| @@ -46,7 +47,7 @@ export const getCiTool: ToolConfig<ServiceNowGetCiParams, ServiceNowGetCiRespons | 46 | 47 |
| 47 | 48 | transformResponse: async (response: Response) => { | 48 | 49 | const data = await parseServiceNowResponse(response) | 49 | | - const result = data.result ?? {} | | 50 | + const result = toRecordObject(data.result) | 50 | 51 | const inboundRelations = Array.isArray(result.inbound_relations) ? result.inbound_relations : [] | 51 | 52 | const outboundRelations = Array.isArray(result.outbound_relations) | 52 | 53 | ? result.outbound_relations | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/servicenow/get_knowledge_article.ts +7-5Lines changed: 7 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 7 | 7 | buildServiceNowHeaders, | 8 | 8 | normalizeInstanceUrl, | 9 | 9 | parseServiceNowResponse, | | 10 | + readString, | | 11 | + toRecordObject, | 10 | 12 | withQueryString, | 11 | 13 | } from '@/tools/servicenow/utils' | 12 | 14 | import type { ToolConfig } from '@/tools/types' | @@ -78,15 +80,15 @@ export const getKnowledgeArticleTool: ToolConfig< | 78 | 80 |
| 79 | 81 | transformResponse: async (response: Response) => { | 80 | 82 | const data = await parseServiceNowResponse(response) | 81 | | - const result = data.result ?? {} | | 83 | + const result = toRecordObject(data.result) | 82 | 84 |
| 83 | 85 | return { | 84 | 86 | success: true, | 85 | 87 | output: { | 86 | | - sysId: result.sys_id ?? null, | 87 | | - number: result.number ?? null, | 88 | | - title: result.short_description ?? null, | 89 | | - content: result.content ?? null, | | 88 | + sysId: readString(result, 'sys_id'), | | 89 | + number: readString(result, 'number'), | | 90 | + title: readString(result, 'short_description'), | | 91 | + content: readString(result, 'content'), | 90 | 92 | fields: result.fields ?? null, | 91 | 93 | attachments: Array.isArray(result.attachments) ? result.attachments : [], | 92 | 94 | }, | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/servicenow/order_catalog_item.ts +8-6Lines changed: 8 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 7 | 7 | buildServiceNowHeaders, | 8 | 8 | normalizeInstanceUrl, | 9 | 9 | parseServiceNowResponse, | | 10 | + readString, | | 11 | + toRecordObject, | 10 | 12 | } from '@/tools/servicenow/utils' | 11 | 13 | import type { ToolConfig } from '@/tools/types' | 12 | 14 |
| @@ -85,16 +87,16 @@ export const orderCatalogItemTool: ToolConfig< | 85 | 87 |
| 86 | 88 | transformResponse: async (response: Response) => { | 87 | 89 | const data = await parseServiceNowResponse(response) | 88 | | - const result = data.result ?? {} | | 90 | + const result = toRecordObject(data.result) | 89 | 91 |
| 90 | 92 | return { | 91 | 93 | success: true, | 92 | 94 | output: { | 93 | | - sysId: result.sys_id ?? null, | 94 | | - number: result.number ?? null, | 95 | | - requestNumber: result.request_number ?? null, | 96 | | - requestId: result.request_id ?? null, | 97 | | - table: result.table ?? null, | | 95 | + sysId: readString(result, 'sys_id'), | | 96 | + number: readString(result, 'number'), | | 97 | + requestNumber: readString(result, 'request_number'), | | 98 | + requestId: readString(result, 'request_id'), | | 99 | + table: readString(result, 'table'), | 98 | 100 | }, | 99 | 101 | } | 100 | 102 | }, | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/servicenow/search_knowledge.ts +4-2Lines changed: 4 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 8 | 8 | buildServiceNowHeaders, | 9 | 9 | normalizeInstanceUrl, | 10 | 10 | parseServiceNowResponse, | | 11 | + readNestedNumber, | | 12 | + toRecordObject, | 11 | 13 | withQueryString, | 12 | 14 | } from '@/tools/servicenow/utils' | 13 | 15 | import type { ToolConfig } from '@/tools/types' | @@ -97,7 +99,7 @@ export const searchKnowledgeTool: ToolConfig< | 97 | 99 |
| 98 | 100 | transformResponse: async (response: Response) => { | 99 | 101 | const data = await parseServiceNowResponse(response) | 100 | | - const result = data.result ?? {} | | 102 | + const result = toRecordObject(data.result) | 101 | 103 | const articles: ServiceNowKnowledgeArticleSummary[] = Array.isArray(result.articles) | 102 | 104 | ? result.articles | 103 | 105 | : [] | @@ -108,7 +110,7 @@ export const searchKnowledgeTool: ToolConfig< | 108 | 110 | articles, | 109 | 111 | metadata: { | 110 | 112 | recordCount: articles.length, | 111 | | - totalCount: typeof result.meta?.count === 'number' ? result.meta.count : null, | | 113 | + totalCount: readNestedNumber(result, 'meta', 'count'), | 112 | 114 | }, | 113 | 115 | }, | 114 | 116 | } | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/servicenow/types.ts +14-3Lines changed: 14 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -9,7 +9,18 @@ import type { ToolResponse } from '@/tools/types' | 9 | 9 | export interface ServiceNowRecord { | 10 | 10 | sys_id?: unknown | 11 | 11 | number?: unknown | 12 | | - [key: string]: any | | 12 | + [key: string]: unknown | | 13 | +} | | 14 | + | | 15 | +/** | | 16 | + * The envelope every ServiceNow REST endpoint replies with. Success bodies | | 17 | + * carry `result` — an object for single-record endpoints, an array for | | 18 | + * collections; failures carry `error`. `result` stays `unknown` so each tool | | 19 | + * narrows it deliberately rather than inheriting an unchecked shape. | | 20 | + */ | | 21 | +export interface ServiceNowEnvelope { | | 22 | + result?: unknown | | 23 | + error?: { message?: string; detail?: string } | string | 13 | 24 | } | 14 | 25 |
| 15 | 26 | /** Credentials shared by every ServiceNow tool. */ | @@ -273,7 +284,7 @@ export interface ServiceNowCatalogItem { | 273 | 284 | picture?: string | 274 | 285 | icon?: string | 275 | 286 | order?: number | 276 | | - [key: string]: any | | 287 | + [key: string]: unknown | 277 | 288 | } | 278 | 289 |
| 279 | 290 | export interface ServiceNowListCatalogItemsResponse extends ToolResponse { | @@ -521,7 +532,7 @@ export interface ServiceNowAttachment { | 521 | 532 | table_name?: string | 522 | 533 | table_sys_id?: string | 523 | 534 | download_link?: string | 524 | | - [key: string]: any | | 535 | + [key: string]: unknown | 525 | 536 | } | 526 | 537 |
| 527 | 538 | export interface ServiceNowListAttachmentsParams { | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/servicenow/utils.ts +43-4Lines changed: 43 additions & 4 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -2,6 +2,7 @@ import { filterUndefined } from '@sim/utils/object' | 2 | 2 | import { DEFAULT_DISPLAY_VALUE } from '@/tools/servicenow/constants' | 3 | 3 | import type { | 4 | 4 | ServiceNowAuthParams, | | 5 | + ServiceNowEnvelope, | 5 | 6 | ServiceNowReadOptions, | 6 | 7 | ServiceNowRecord, | 7 | 8 | ServiceNowWriteOptions, | @@ -132,17 +133,55 @@ export function withQueryString(url: string, searchParams: URLSearchParams): str | 132 | 133 | * Parses a ServiceNow JSON response, raising the instance's own error message | 133 | 134 | * when the request failed. ServiceNow error bodies are `{ error: { message, detail } }`. | 134 | 135 | */ | 135 | | -export async function parseServiceNowResponse(response: Response): Promise<any> { | 136 | | - const data = await response.json() | | 136 | +export async function parseServiceNowResponse(response: Response): Promise<ServiceNowEnvelope> { | | 137 | + const data = (await response.json()) as ServiceNowEnvelope | 137 | 138 |
| 138 | 139 | if (!response.ok) { | 139 | | - const error = data?.error || data | 140 | | - throw new Error(typeof error === 'string' ? error : error?.message || JSON.stringify(error)) | | 140 | + const error = data?.error ?? data | | 141 | + const message = | | 142 | + typeof error === 'string' | | 143 | + ? error | | 144 | + : ((error as { message?: string })?.message ?? JSON.stringify(error)) | | 145 | + throw new Error(message) | 141 | 146 | } | 142 | 147 |
| 143 | 148 | return data | 144 | 149 | } | 145 | 150 |
| | 151 | +/** | | 152 | + * Narrows an envelope `result` to a keyed record. Single-record endpoints reply | | 153 | + * with an object; anything else (a collection, a scalar, `null`) becomes `{}` so | | 154 | + * callers can read fields without an unchecked cast. | | 155 | + */ | | 156 | +export function toRecordObject(result: unknown): ServiceNowRecord { | | 157 | + return result && typeof result === 'object' && !Array.isArray(result) | | 158 | + ? (result as ServiceNowRecord) | | 159 | + : {} | | 160 | +} | | 161 | + | | 162 | +/** | | 163 | + * Reads a string field off a ServiceNow record. Returns `null` when the field is | | 164 | + * absent or is not a string, so a tool declaring a `string | null` output cannot | | 165 | + * silently emit an object because the instance returned a different shape. | | 166 | + */ | | 167 | +export function readString(record: ServiceNowRecord, key: string): string | null { | | 168 | + const value = record[key] | | 169 | + return typeof value === 'string' ? value : null | | 170 | +} | | 171 | + | | 172 | +/** | | 173 | + * Reads a number field off a nested object on a ServiceNow record, for example | | 174 | + * the `meta.count` the Knowledge search API returns alongside its results. | | 175 | + */ | | 176 | +export function readNestedNumber( | | 177 | + record: ServiceNowRecord, | | 178 | + key: string, | | 179 | + nestedKey: string | | 180 | +): number | null { | | 181 | + const nested = toRecordObject(record[key])[nestedKey] | | 182 | + return typeof nested === 'number' ? nested : null | | 183 | +} | | 184 | + | 146 | 185 | /** | 147 | 186 | * Normalizes the `{ result: ... }` envelope into an array of records. Collection | 148 | 187 | * endpoints return an array, single-record endpoints return an object. | |
|