From 23454959609a96d01b62d23f2007e7688fa3b6de Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Thu, 14 Sep 2023 18:21:58 +0200 Subject: [PATCH 1/5] feat: validate API response --- cli/commands/configure-device.ts | 5 +- nrfcloud/apiClient.ts | 223 ++++++++++++++++++++----------- 2 files changed, 151 insertions(+), 77 deletions(-) diff --git a/cli/commands/configure-device.ts b/cli/commands/configure-device.ts index 00289ce3c..7388ee3fc 100644 --- a/cli/commands/configure-device.ts +++ b/cli/commands/configure-device.ts @@ -4,9 +4,10 @@ import chalk from 'chalk' import { isEqual } from 'lodash-es' import { table } from 'table' import { getDevice } from '../../devices/getDevice.js' -import { apiClient, type DeviceConfig } from '../../nrfcloud/apiClient.js' +import { apiClient, DeviceConfig } from '../../nrfcloud/apiClient.js' import { getAPISettings } from '../../nrfcloud/settings.js' import type { CommandDefinition } from './CommandDefinition.js' +import type { Static } from '@sinclair/typebox' const defaultActiveWaitTimeSeconds = 120 const defaultLocationTimeoutSeconds = 60 @@ -94,7 +95,7 @@ export const configureDeviceCommand = ({ ...Object.keys(state?.reported?.config ?? {}), ...Object.keys(state?.desired?.config ?? {}), ]), - ] as (keyof DeviceConfig)[] + ] as (keyof Static)[] console.log( table( diff --git a/nrfcloud/apiClient.ts b/nrfcloud/apiClient.ts index 9b81159d3..b49ae06fe 100644 --- a/nrfcloud/apiClient.ts +++ b/nrfcloud/apiClient.ts @@ -1,71 +1,118 @@ +import { Type, type TSchema, type Static } from '@sinclair/typebox' import { slashless } from '../util/slashless.js' import type { Nullable } from '../util/types.js' +import { validateWithTypeBox } from '@hello.nrfcloud.com/proto' + +export const DeviceConfig = Type.Partial( + Type.Object({ + activeMode: Type.Boolean(), // e.g. false + locationTimeout: Type.Number(), // e.g. 300 + activeWaitTime: Type.Number(), // e.g. 120 + movementResolution: Type.Number(), // e.g. 120 + movementTimeout: Type.Number(), // e.g. 3600 + accThreshAct: Type.Number(), // e.g. 4 + accThreshInact: Type.Number(), // e.g. 4 + accTimeoutInact: Type.Number(), // e.g. 60 + nod: Type.Array( + Type.Union([ + Type.Literal('gnss'), + Type.Literal('ncell'), + Type.Literal('wifi'), + ]), + ), // e.g. ['nod'] + }), +) + +const Device = Type.Object({ + id: Type.String(), + state: Type.Optional( + Type.Object({ + reported: Type.Optional( + Type.Object({ + config: Type.Optional(DeviceConfig), + connection: Type.Optional( + Type.Object({ + status: Type.Optional( + Type.Union([ + Type.Literal('connected'), + Type.Literal('disconnected'), + ]), + ), + }), + ), + device: Type.Optional( + Type.Object({ + deviceInfo: Type.Optional( + Type.Partial( + Type.Object({ + appVersion: Type.String(), // e.g. '1.1.0' + modemFirmware: Type.String(), // e.g. 'mfw_nrf9160_1.3.4' + imei: Type.String(), // e.g. '352656108602296' + board: Type.String(), // e.g. 'thingy91_nrf9160' + hwVer: Type.String(), // e.g. 'nRF9160 SICA B1A' + }), + ), + ), + }), + ), + }), + ), + desired: Type.Optional( + Type.Object({ + config: Type.Optional(DeviceConfig), + }), + ), + version: Type.Number(), + }), + ), +}) + +const Page = (Item: T) => + Type.Object({ + total: Type.Integer(), + items: Type.Array(Item), + }) +const Devices = Page(Device) + +const AccountInfo = Type.Object({ + mqttEndpoint: Type.String(), // e.g. 'mqtt.nrfcloud.com' + mqttTopicPrefix: Type.String(), // e.g. 'prod/a0673464-e4e1-4b87-bffd-6941a012067b/', + team: Type.Object({ + tenantId: Type.String(), // e.g. 'bbfe6b73-a46a-43ad-94bd-8e4b4a7847ce', + name: Type.String(), // e.g. 'hello.nrfcloud.com' + }), + plan: Type.Object({ + currentMonthCosts: Type.Array( + Type.Object({ + price: Type.Number(), // e.g. 0.1 + quantity: Type.Number(), // e.g. 9 + serviceDescription: Type.String(), // e.g. 'Devices in your account' + serviceId: Type.Union([ + Type.Literal('Devices'), + Type.Literal('Messages'), + Type.Literal('SCELL'), + Type.Literal('MCELL'), + ]), + total: Type.Number(), // e.g. 0.9 + }), + ), + currentMonthTotalCost: Type.Number(), // e.g. 2.73 + name: Type.Union([Type.Literal('PRO'), Type.Literal('DEVELOPER')]), + proxyUsageDeclarations: Type.Object({ + AGPS: Type.Number(), // e.g. 0 + GROUND_FIX: Type.Number(), // e.g. 200 + PGPS: Type.Number(), // e.g. 0 + }), + }), + role: Type.Union([ + Type.Literal('owner'), + Type.Literal('admin'), + Type.Literal('editor'), + Type.Literal('viewer'), + ]), + tags: Type.Array(Type.String()), +}) -export type DeviceConfig = Partial<{ - activeMode: boolean // e.g. false - locationTimeout: number // e.g. 300 - activeWaitTime: number // e.g. 120 - movementResolution: number // e.g. 120 - movementTimeout: number // e.g. 3600 - accThreshAct: number // e.g. 4 - accThreshInact: number // e.g. 4 - accTimeoutInact: number // e.g. 60 - nod: ('gnss' | 'ncell' | 'wifi')[] // e.g. ['nod'] -}> -export type Device = { - id: string // e.g. 'oob-352656108602296' - state?: { - reported?: { - config?: DeviceConfig - connection?: { - status?: 'connected' | 'disconnected' - } - device?: { - deviceInfo?: Partial<{ - appVersion: string // e.g. '1.1.0' - modemFirmware: string // e.g. 'mfw_nrf9160_1.3.4' - imei: string // e.g. '352656108602296' - board: string // e.g. 'thingy91_nrf9160' - hwVer: string // e.g. 'nRF9160 SICA B1A' - }> - } - } - desired?: { - config?: DeviceConfig - } - version: number - } -} -type Page = { - total: number - items: Item[] -} -type AccountInfo = { - mqttEndpoint: string // e.g. 'mqtt.nrfcloud.com' - mqttTopicPrefix: string // e.g. 'prod/a0673464-e4e1-4b87-bffd-6941a012067b/', - team: { - tenantId: string // e.g. 'bbfe6b73-a46a-43ad-94bd-8e4b4a7847ce', - name: string // e.g. 'hello.nrfcloud.com' - } - plan: { - currentMonthCosts: { - price: number // e.g. 0.1 - quantity: number // e.g. 9 - serviceDescription: string // e.g. 'Devices in your account' - serviceId: 'Devices' | 'Messages' | 'SCELL' | 'MCELL' - total: number // e.g. 0.9 - }[] - currentMonthTotalCost: number // e.g. 2.73 - name: 'PRO' | 'DEVELOPER' - proxyUsageDeclarations: { - AGPS: number // e.g. 0 - GROUND_FIX: number // e.g. 200 - PGPS: number // e.g. 0 - } - } - role: 'owner' | 'admin' | 'editor' | 'viewer' - tags: string[] -} type FwType = | 'APP' | 'MODEM' @@ -81,11 +128,16 @@ export const apiClient = ({ endpoint: URL apiKey: string }): { - listDevices: () => Promise<{ error: Error } | { devices: Page }> - getDevice: (id: string) => Promise<{ error: Error } | { device: Device }> + listDevices: () => Promise< + { error: Error } | { devices: Static } + > + getDevice: ( + id: string, + ) => Promise<{ error: Error } | { device: Static }> updateConfig: ( id: string, - config: Nullable> & Pick, + config: Nullable, 'nod'>> & + Pick, 'nod'>, ) => Promise<{ error: Error } | { success: boolean }> registerDevices: ( devices: { @@ -104,7 +156,7 @@ export const apiClient = ({ account: () => Promise< | { error: Error } | { - account: AccountInfo + account: Static } > getBulkOpsStatus: (bulkOpsId: string) => Promise< @@ -136,14 +188,28 @@ export const apiClient = ({ }).toString()}`, { headers }, ) - .then>(async (res) => res.json()) - .then((devices) => ({ devices })), + .then>(async (res) => res.json()) + .then((devices) => { + const maybeDevices = validateWithTypeBox(Devices)(devices) + if ('errors' in maybeDevices) { + throw new Error(`Failed to validate response of 'listDevices' API`) + } + + return { devices: maybeDevices.value } + }), getDevice: async (id) => fetch(`${slashless(endpoint)}/v1/devices/${encodeURIComponent(id)}`, { headers, }) - .then(async (res) => res.json()) - .then((device) => ({ device })), + .then>(async (res) => res.json()) + .then((device) => { + const maybeDevice = validateWithTypeBox(Device)(device) + if ('errors' in maybeDevice) { + throw new Error(`Failed to validate response of 'getDevice' API`) + } + + return { device: maybeDevice.value } + }), updateConfig: async (id, config) => fetch( `${slashless(endpoint)}/v1/devices/${encodeURIComponent(id)}/state`, @@ -221,8 +287,15 @@ export const apiClient = ({ 'Content-Type': 'application/octet-stream', }, }) - .then(async (res) => res.json()) - .then((account) => ({ account })) + .then>(async (res) => res.json()) + .then((account) => { + const maybeAccount = validateWithTypeBox(AccountInfo)(account) + if ('errors' in maybeAccount) { + throw new Error(`Failed to validate response of 'account' API`) + } + + return { account: maybeAccount.value } + }) .catch((err) => ({ error: err as Error })), getBulkOpsStatus: async (bulkOpsId) => fetch( From 8a21c27ad13598602207a90280241b39ccdc460c Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Fri, 15 Sep 2023 14:04:59 +0200 Subject: [PATCH 2/5] feat: throw proper error when validation fails --- nrfcloud/apiClient.ts | 64 ++++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/nrfcloud/apiClient.ts b/nrfcloud/apiClient.ts index b49ae06fe..a98b3ccbb 100644 --- a/nrfcloud/apiClient.ts +++ b/nrfcloud/apiClient.ts @@ -1,7 +1,13 @@ -import { Type, type TSchema, type Static } from '@sinclair/typebox' +import { + Type, + type TSchema, + type Static, + type TObject, +} from '@sinclair/typebox' import { slashless } from '../util/slashless.js' import type { Nullable } from '../util/types.js' import { validateWithTypeBox } from '@hello.nrfcloud.com/proto' +import type { ErrorObject } from 'ajv' export const DeviceConfig = Type.Partial( Type.Object({ @@ -121,6 +127,27 @@ type FwType = | 'BOOTLOADER' | 'MDM_FULL' +class ValidationError extends Error { + public errors: ErrorObject[] + constructor(errors: ErrorObject[]) { + super(`Validation errors`) + this.name = 'ValidationError' + this.errors = errors + } +} + +const validate = ( + SchemaObject: T, + data: unknown, +): Static => { + const maybeResponse = validateWithTypeBox(SchemaObject)(data) + if ('errors' in maybeResponse) { + throw new ValidationError(maybeResponse.errors) + } + + return maybeResponse.value +} + export const apiClient = ({ endpoint, apiKey, @@ -129,11 +156,11 @@ export const apiClient = ({ apiKey: string }): { listDevices: () => Promise< - { error: Error } | { devices: Static } + { error: ValidationError } | { devices: Static } > getDevice: ( id: string, - ) => Promise<{ error: Error } | { device: Static }> + ) => Promise<{ error: ValidationError } | { device: Static }> updateConfig: ( id: string, config: Nullable, 'nod'>> & @@ -154,7 +181,7 @@ export const apiClient = ({ }[], ) => Promise<{ error: Error } | { bulkOpsRequestId: string }> account: () => Promise< - | { error: Error } + | { error: ValidationError } | { account: Static } @@ -190,26 +217,18 @@ export const apiClient = ({ ) .then>(async (res) => res.json()) .then((devices) => { - const maybeDevices = validateWithTypeBox(Devices)(devices) - if ('errors' in maybeDevices) { - throw new Error(`Failed to validate response of 'listDevices' API`) - } - - return { devices: maybeDevices.value } - }), + return { devices: validate(Devices, devices) } + }) + .catch((error) => ({ error: error as ValidationError })), getDevice: async (id) => fetch(`${slashless(endpoint)}/v1/devices/${encodeURIComponent(id)}`, { headers, }) .then>(async (res) => res.json()) .then((device) => { - const maybeDevice = validateWithTypeBox(Device)(device) - if ('errors' in maybeDevice) { - throw new Error(`Failed to validate response of 'getDevice' API`) - } - - return { device: maybeDevice.value } - }), + return { device: validate(Device, device) } + }) + .catch((error) => ({ error: error as ValidationError })), updateConfig: async (id, config) => fetch( `${slashless(endpoint)}/v1/devices/${encodeURIComponent(id)}/state`, @@ -289,14 +308,9 @@ export const apiClient = ({ }) .then>(async (res) => res.json()) .then((account) => { - const maybeAccount = validateWithTypeBox(AccountInfo)(account) - if ('errors' in maybeAccount) { - throw new Error(`Failed to validate response of 'account' API`) - } - - return { account: maybeAccount.value } + return { account: validate(AccountInfo, account) } }) - .catch((err) => ({ error: err as Error })), + .catch((error) => ({ error: error as ValidationError })), getBulkOpsStatus: async (bulkOpsId) => fetch( `${slashless(endpoint)}/v1/bulk-ops-requests/${encodeURIComponent( From e1d68da5714d6059b2dc58543626f54b9f77227b Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Mon, 18 Sep 2023 11:38:48 +0200 Subject: [PATCH 3/5] fix: handle fetch error --- nrfcloud/apiClient.ts | 100 +++++++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/nrfcloud/apiClient.ts b/nrfcloud/apiClient.ts index a98b3ccbb..687c47055 100644 --- a/nrfcloud/apiClient.ts +++ b/nrfcloud/apiClient.ts @@ -119,6 +119,22 @@ const AccountInfo = Type.Object({ tags: Type.Array(Type.String()), }) +const BulkOpsRequest = Type.Object({ + bulkOpsRequestId: Type.String(), // e.g. '01EZZJVDQJPWT7V4FWNVDHNMM5' + endpoint: Type.String(), // e.g. 'PROVISION_DEVICES' + status: Type.Union([ + Type.Literal('PENDING'), + Type.Literal('IN_PROGRESS'), + Type.Literal('FAILED'), + Type.Literal('SUCCEEDED'), + ]), // e.g. 'PENDING' + requestedAt: Type.String(), // e.g. '2020-06-25T21:05:12.830Z' + completedAt: Type.String(), // e.g. '2020-06-25T21:05:12.830Z' + uploadedDataUrl: Type.String(), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5.csv' + resultDataUrl: Type.Optional(Type.String()), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5-result.json' + errorSummaryUrl: Type.Optional(Type.String()), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5.json' +}) + type FwType = | 'APP' | 'MODEM' @@ -129,6 +145,7 @@ type FwType = class ValidationError extends Error { public errors: ErrorObject[] + public readonly isValidationError = true constructor(errors: ErrorObject[]) { super(`Validation errors`) this.name = 'ValidationError' @@ -136,18 +153,28 @@ class ValidationError extends Error { } } -const validate = ( +const validate = async ( SchemaObject: T, - data: unknown, -): Static => { - const maybeResponse = validateWithTypeBox(SchemaObject)(data) - if ('errors' in maybeResponse) { - throw new ValidationError(maybeResponse.errors) - } + response: Response, +): Promise> => { + if (response.ok) { + const maybeResponse = validateWithTypeBox(SchemaObject)( + await response.json(), + ) + if ('errors' in maybeResponse) { + throw new ValidationError(maybeResponse.errors) + } - return maybeResponse.value + return maybeResponse.value + } else { + throw new Error(`Error fetching status: ${response.status}`) + } } +const onError = (error: Error): { error: Error | ValidationError } => ({ + error, +}) + export const apiClient = ({ endpoint, apiKey, @@ -156,11 +183,13 @@ export const apiClient = ({ apiKey: string }): { listDevices: () => Promise< - { error: ValidationError } | { devices: Static } + { error: Error | ValidationError } | { devices: Static } > getDevice: ( id: string, - ) => Promise<{ error: ValidationError } | { device: Static }> + ) => Promise< + { error: Error | ValidationError } | { device: Static } + > updateConfig: ( id: string, config: Nullable, 'nod'>> & @@ -181,24 +210,15 @@ export const apiClient = ({ }[], ) => Promise<{ error: Error } | { bulkOpsRequestId: string }> account: () => Promise< - | { error: ValidationError } + | { error: Error | ValidationError } | { account: Static } > getBulkOpsStatus: (bulkOpsId: string) => Promise< - | { error: Error } + | { error: Error | ValidationError } | { - status: { - bulkOpsRequestId: string // e.g. '01EZZJVDQJPWT7V4FWNVDHNMM5' - endpoint: string // e.g. 'PROVISION_DEVICES' - status: 'PENDING' | 'IN_PROGRESS' | 'FAILED' | 'SUCCEEDED' // e.g. 'PENDING' - requestedAt: string // e.g. '2020-06-25T21:05:12.830Z' - completedAt: string // e.g. '2020-06-25T21:05:12.830Z' - uploadedDataUrl: string // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5.csv' - resultDataUrl?: string // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5-result.json' - errorSummaryUrl?: string // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5.json' - } + status: Static } > } => { @@ -215,20 +235,14 @@ export const apiClient = ({ }).toString()}`, { headers }, ) - .then>(async (res) => res.json()) - .then((devices) => { - return { devices: validate(Devices, devices) } - }) - .catch((error) => ({ error: error as ValidationError })), + .then(async (res) => ({ devices: await validate(Devices, res) })) + .catch(onError), getDevice: async (id) => fetch(`${slashless(endpoint)}/v1/devices/${encodeURIComponent(id)}`, { headers, }) - .then>(async (res) => res.json()) - .then((device) => { - return { device: validate(Device, device) } - }) - .catch((error) => ({ error: error as ValidationError })), + .then(async (res) => ({ device: await validate(Device, res) })) + .catch(onError), updateConfig: async (id, config) => fetch( `${slashless(endpoint)}/v1/devices/${encodeURIComponent(id)}/state`, @@ -246,11 +260,10 @@ export const apiClient = ({ }, ) .then((res) => { - if (res.status >= 400) - return { error: new Error(`Update failed: ${res.status}`) } + if (res.status >= 400) throw new Error(`Update failed: ${res.status}`) return { success: true } }) - .catch((error) => ({ error: error as Error })), + .catch(onError), registerDevices: async (devices) => { const bulkRegistrationPayload = devices .map(({ deviceId, subType, tags, fwTypes, certPem }) => [ @@ -306,11 +319,10 @@ export const apiClient = ({ 'Content-Type': 'application/octet-stream', }, }) - .then>(async (res) => res.json()) - .then((account) => { - return { account: validate(AccountInfo, account) } - }) - .catch((error) => ({ error: error as ValidationError })), + .then(async (account) => ({ + account: await validate(AccountInfo, account), + })) + .catch(onError), getBulkOpsStatus: async (bulkOpsId) => fetch( `${slashless(endpoint)}/v1/bulk-ops-requests/${encodeURIComponent( @@ -323,11 +335,7 @@ export const apiClient = ({ }, }, ) - .then(async (res) => { - if (res.status >= 400) - return { error: new Error(`Error fetching status: ${res.status}`) } - return { status: await res.json() } - }) - .catch((error) => ({ error: error as Error })), + .then(async (res) => ({ status: await validate(BulkOpsRequest, res) })) + .catch(onError), } } From d5a7983951d699840ca1dae60bc5471881b8bef6 Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Mon, 18 Sep 2023 16:32:08 +0200 Subject: [PATCH 4/5] feat: validate date --- nrfcloud/apiClient.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/nrfcloud/apiClient.ts b/nrfcloud/apiClient.ts index 687c47055..7c9dbf0cd 100644 --- a/nrfcloud/apiClient.ts +++ b/nrfcloud/apiClient.ts @@ -121,15 +121,29 @@ const AccountInfo = Type.Object({ const BulkOpsRequest = Type.Object({ bulkOpsRequestId: Type.String(), // e.g. '01EZZJVDQJPWT7V4FWNVDHNMM5' - endpoint: Type.String(), // e.g. 'PROVISION_DEVICES' + endpoint: Type.Union([ + Type.Literal('PROVISION_DEVICES'), + Type.Literal('REGISTER_PUBLIC_KEYS'), + Type.Literal('VERIFY_ATTESTATION_TOKENS'), + Type.Literal('VERIFY_JWTS'), + Type.Literal('CLAIM_DEVICE_OWNERSHIP'), + ]), // e.g. 'PROVISION_DEVICES' status: Type.Union([ Type.Literal('PENDING'), Type.Literal('IN_PROGRESS'), Type.Literal('FAILED'), Type.Literal('SUCCEEDED'), ]), // e.g. 'PENDING' - requestedAt: Type.String(), // e.g. '2020-06-25T21:05:12.830Z' - completedAt: Type.String(), // e.g. '2020-06-25T21:05:12.830Z' + requestedAt: Type.String({ + pattern: + '^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+([+-][0-2]\\d:[0-5]\\d|Z)$', + }), // e.g. '2020-06-25T21:05:12.830Z' + completedAt: Type.Optional( + Type.String({ + pattern: + '^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+([+-][0-2]\\d:[0-5]\\d|Z)$', + }), + ), // e.g. '2020-06-25T21:05:12.830Z' uploadedDataUrl: Type.String(), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5.csv' resultDataUrl: Type.Optional(Type.String()), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5-result.json' errorSummaryUrl: Type.Optional(Type.String()), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5.json' From 889b96b481b0b196cec79fbe77f5d120b3d3d0e8 Mon Sep 17 00:00:00 2001 From: Pisut Sritrakulchai Date: Tue, 19 Sep 2023 12:17:37 +0200 Subject: [PATCH 5/5] fix: refactor code --- nrfcloud/apiClient.ts | 66 +++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/nrfcloud/apiClient.ts b/nrfcloud/apiClient.ts index 7c9dbf0cd..b99b9e03d 100644 --- a/nrfcloud/apiClient.ts +++ b/nrfcloud/apiClient.ts @@ -9,6 +9,12 @@ import type { Nullable } from '../util/types.js' import { validateWithTypeBox } from '@hello.nrfcloud.com/proto' import type { ErrorObject } from 'ajv' +const DateString = Type.String({ + pattern: + '^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+([+-][0-2]\\d:[0-5]\\d|Z)$', + title: 'ISO Date string', +}) + export const DeviceConfig = Type.Partial( Type.Object({ activeMode: Type.Boolean(), // e.g. false @@ -134,16 +140,8 @@ const BulkOpsRequest = Type.Object({ Type.Literal('FAILED'), Type.Literal('SUCCEEDED'), ]), // e.g. 'PENDING' - requestedAt: Type.String({ - pattern: - '^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+([+-][0-2]\\d:[0-5]\\d|Z)$', - }), // e.g. '2020-06-25T21:05:12.830Z' - completedAt: Type.Optional( - Type.String({ - pattern: - '^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+([+-][0-2]\\d:[0-5]\\d|Z)$', - }), - ), // e.g. '2020-06-25T21:05:12.830Z' + requestedAt: DateString, // e.g. '2020-06-25T21:05:12.830Z' + completedAt: Type.Optional(DateString), // e.g. '2020-06-25T21:05:12.830Z' uploadedDataUrl: Type.String(), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5.csv' resultDataUrl: Type.Optional(Type.String()), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5-result.json' errorSummaryUrl: Type.Optional(Type.String()), // e.g. 'https://bulk-ops-requests.nrfcloud.com/a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9/provision_devices/01EZZJVDQJPWT7V4FWNVDHNMM5.json' @@ -167,22 +165,26 @@ class ValidationError extends Error { } } -const validate = async ( +const validate = ( SchemaObject: T, - response: Response, -): Promise> => { - if (response.ok) { - const maybeResponse = validateWithTypeBox(SchemaObject)( - await response.json(), - ) - if ('errors' in maybeResponse) { - throw new ValidationError(maybeResponse.errors) - } + data: unknown, +): Static => { + const maybeData = validateWithTypeBox(SchemaObject)(data) - return maybeResponse.value - } else { - throw new Error(`Error fetching status: ${response.status}`) + if ('errors' in maybeData) { + throw new ValidationError(maybeData.errors) } + + return maybeData.value +} + +const fetchData = async ( + ...args: Parameters +): ReturnType => { + const response = await fetch(...args) + if (!response.ok) throw new Error(`Error fetching status: ${response.status}`) + + return response.json() } const onError = (error: Error): { error: Error | ValidationError } => ({ @@ -242,20 +244,20 @@ export const apiClient = ({ } return { listDevices: async () => - fetch( + fetchData( `${slashless(endpoint)}/v1/devices?${new URLSearchParams({ pageLimit: '100', deviceNameFuzzy: 'oob-', }).toString()}`, { headers }, ) - .then(async (res) => ({ devices: await validate(Devices, res) })) + .then((res) => ({ devices: validate(Devices, res) })) .catch(onError), getDevice: async (id) => - fetch(`${slashless(endpoint)}/v1/devices/${encodeURIComponent(id)}`, { + fetchData(`${slashless(endpoint)}/v1/devices/${encodeURIComponent(id)}`, { headers, }) - .then(async (res) => ({ device: await validate(Device, res) })) + .then((res) => ({ device: validate(Device, res) })) .catch(onError), updateConfig: async (id, config) => fetch( @@ -327,18 +329,16 @@ export const apiClient = ({ return { error: new Error(`Import failed: ${JSON.stringify(res)}`) } }, account: async () => - fetch(`${slashless(endpoint)}/v1/account`, { + fetchData(`${slashless(endpoint)}/v1/account`, { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/octet-stream', }, }) - .then(async (account) => ({ - account: await validate(AccountInfo, account), - })) + .then((res) => ({ account: validate(AccountInfo, res) })) .catch(onError), getBulkOpsStatus: async (bulkOpsId) => - fetch( + fetchData( `${slashless(endpoint)}/v1/bulk-ops-requests/${encodeURIComponent( bulkOpsId, )}`, @@ -349,7 +349,7 @@ export const apiClient = ({ }, }, ) - .then(async (res) => ({ status: await validate(BulkOpsRequest, res) })) + .then((res) => ({ status: validate(BulkOpsRequest, res) })) .catch(onError), } }