diff --git a/lambda/loggingFetch.ts b/lambda/loggingFetch.ts index f82ebd9d8..1ed5e41bc 100644 --- a/lambda/loggingFetch.ts +++ b/lambda/loggingFetch.ts @@ -4,7 +4,10 @@ import type { Logger } from '@aws-lambda-powertools/logger' export const loggingFetch = ({ track, log }: { track: AddMetricsFn; log: Logger }) => - async (url: URL, init?: RequestInit): ReturnType => { + async ( + url: URL | RequestInfo, + init?: RequestInit, + ): ReturnType => { log.debug(`fetch:url`, url.toString()) if (init?.body !== null && init?.body !== undefined) log.debug(`fetch:body`, init.body.toString()) diff --git a/lambda/resolveSingleCellGeoLocation.ts b/lambda/resolveSingleCellGeoLocation.ts index 944e98123..7a9be9ff0 100644 --- a/lambda/resolveSingleCellGeoLocation.ts +++ b/lambda/resolveSingleCellGeoLocation.ts @@ -2,7 +2,6 @@ import { MetricUnits, logMetrics } from '@aws-lambda-powertools/metrics' import { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { EventBridge } from '@aws-sdk/client-eventbridge' import { SSMClient } from '@aws-sdk/client-ssm' -import { validateWithTypeBox } from '@hello.nrfcloud.com/proto' import { Context, SingleCellGeoLocation, @@ -18,12 +17,12 @@ import { once } from 'lodash-es' import { get, store } from '../cellGeoLocation/SingleCellGeoLocationCache.js' import { cellId } from '../cellGeoLocation/cellId.js' import { getAllAccountsSettings } from '../nrfcloud/allAccounts.js' -import { slashless } from '../util/slashless.js' import { getDeviceAttributesById } from './getDeviceAttributes.js' -import { loggingFetch } from './loggingFetch.js' import { metricsForComponent } from './metrics/metrics.js' import type { WebsocketPayload } from './publishToWebsocketClients.js' import { logger } from './util/logger.js' +import { JSONPayload, validatedFetch } from '../nrfcloud/validatedFetch.js' +import { loggingFetch } from './loggingFetch.js' const { EventBusName, stackName, DevicesTableName, cacheTableName } = fromEnv({ EventBusName: 'EVENTBUS_NAME', @@ -37,6 +36,23 @@ const eventBus = new EventBridge({}) const db = new DynamoDBClient({}) const ssm = new SSMClient({}) +/** + * @link https://api.nrfcloud.com/v1/#tag/Account/operation/GetServiceToken + */ +const ServiceToken = Type.Object({ + token: Type.String(), +}) + +/** + * @link https://api.nrfcloud.com/v1/#tag/Ground-Fix + */ +const GroundFix = Type.Object({ + lat: TLat, // 63.41999531 + lon: TLng, // 10.42999506 + uncertainty: TAccuracy, // 2420 + fulfilledWith: Type.Literal('SCELL'), +}) + const deviceFetcher = getDeviceAttributesById({ db, DevicesTableName }) const { track, metrics } = metricsForComponent('singleCellGeo') @@ -54,30 +70,18 @@ const cache = store({ const serviceToken = once( async ({ apiEndpoint, apiKey }: { apiEndpoint: URL; apiKey: string }) => { - // FIXME: validate response - const res = await trackFetch( - new URL(`${slashless(apiEndpoint)}/v1/account/service-token`), - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - }, + const vf = validatedFetch({ endpoint: apiEndpoint, apiKey }, trackFetch) + const maybeResult = await vf( + { resource: 'account/service-token' }, + ServiceToken, ) - if (!res.ok) { - const body = await res.text() - console.error('request failed', { - body, - status: res.status, - }) - throw new Error(`Acquiring service token failed: ${body} (${res.status})`) - } - const { token } = (await res.json()) as { - createdAt: string // e.g. '2022-12-19T19:39:02.655Z' - token: string // JWT + + if ('error' in maybeResult) { + log.error(`Acquiring service token failed`, { error: maybeResult.error }) + throw maybeResult.error } - return token + + return maybeResult.result.token }, ) @@ -153,44 +157,27 @@ const h = async (event: { }, ], } - // FIXME: validate response - const res = await trackFetch( - new URL(`${slashless(apiEndpoint)}/v1/location/ground-fix`), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${locationServiceToken}`, - }, - body: JSON.stringify(body), - }, + + const vf = validatedFetch( + { endpoint: apiEndpoint, apiKey: locationServiceToken }, + trackFetch, + ) + const maybeResult = await vf( + { resource: 'location/ground-fix', payload: JSONPayload(body) }, + GroundFix, ) - if (!res.ok) { + if ('error' in maybeResult) { track('single-cell:error', MetricUnits.Count, 1) - log.error('request failed', { - body: await res.text(), - status: res.status, + log.error('Failed to resolve cell location:', { + error: maybeResult.error, }) + return } - const response = await res.json() - const maybeLocation = validateWithTypeBox( - Type.Object({ - lat: TLat, // 63.41999531 - lon: TLng, // 10.42999506 - uncertainty: TAccuracy, // 2420 - fulfilledWith: Type.Literal('SCELL'), - }), - )(response) - if ('errors' in maybeLocation) { - throw new Error( - `Failed to resolve cell location: ${JSON.stringify(response)}`, - ) - } track('single-cell:resolved', MetricUnits.Count, 1) - const { lat, lon, uncertainty } = maybeLocation.value + const { lat, lon, uncertainty } = maybeResult.result message = { '@context': Context.singleCellGeoLocation.toString(), lat, diff --git a/lambda/ws/sendShadowToConnection.ts b/lambda/ws/sendShadowToConnection.ts index 2792b3c1f..9922335bd 100644 --- a/lambda/ws/sendShadowToConnection.ts +++ b/lambda/ws/sendShadowToConnection.ts @@ -5,7 +5,7 @@ import { PutEventsCommand, } from '@aws-sdk/client-eventbridge' import { proto } from '@hello.nrfcloud.com/proto/hello/model/PCA20035+solar' -import { type DeviceShadow } from '../../nrfcloud/DeviceShadow.js' +import { type DeviceShadowType } from '../../nrfcloud/DeviceShadow.js' import type { AddMetricsFn } from '../metrics/metrics.js' import type { WebsocketPayload } from '../publishToWebsocketClients.js' @@ -26,7 +26,7 @@ export const sendShadowToConnection = connectionId, shadow, }: { - shadow: DeviceShadow + shadow: DeviceShadowType model: string connectionId: string }): Promise => { diff --git a/nrfcloud/DeviceShadow.spec.ts b/nrfcloud/DeviceShadow.spec.ts new file mode 100644 index 000000000..a53df299c --- /dev/null +++ b/nrfcloud/DeviceShadow.spec.ts @@ -0,0 +1,52 @@ +import { validateWithTypeBox } from '@hello.nrfcloud.com/proto' +import { DeviceShadow } from './DeviceShadow.js' + +describe('DeviceShadow type', () => { + it('should document the device shadow object', () => { + const res = validateWithTypeBox(DeviceShadow)({ + id: 'some-device', + state: { + version: 42, + reported: { + dev: { + v: { + imei: '358299840016535', + iccid: '89450421180216254864', + modV: 'mfw_nrf91x1_2.0.0-77.beta', + brdV: 'thingy91x_nrf9161', + appV: '0.0.0-development', + }, + ts: 1697102116821, + }, + }, + metadata: { + reported: { + dev: { + v: { + imei: { + timestamp: 1697102122, + }, + iccid: { + timestamp: 1697102122, + }, + modV: { + timestamp: 1697102122, + }, + brdV: { + timestamp: 1697102122, + }, + appV: { + timestamp: 1697102122, + }, + }, + ts: { + timestamp: 1697102122, + }, + }, + }, + }, + }, + }) + expect(res).not.toHaveProperty('errors') + }) +}) diff --git a/nrfcloud/DeviceShadow.ts b/nrfcloud/DeviceShadow.ts index 30f3edea7..1a37096be 100644 --- a/nrfcloud/DeviceShadow.ts +++ b/nrfcloud/DeviceShadow.ts @@ -1,8 +1,22 @@ -export type DeviceShadow = { - id: string - state: { - reported: Record - version: number - metadata: Record - } -} +import { Type, type Static } from '@sinclair/typebox' + +const PropertyMetadata = Type.Union([ + Type.Object({ timestamp: Type.Integer({ minimum: 1, maximum: 9999999999 }) }), + Type.Record(Type.String({ minLength: 1 }), Type.Unknown()), +]) + +/** + * @link https://api.nrfcloud.com/v1/#tag/All-Devices/operation/ListDevices + */ +export const DeviceShadow = Type.Object({ + id: Type.String(), + state: Type.Object({ + reported: Type.Object({}), + version: Type.Number(), + metadata: Type.Object({ + reported: Type.Record(Type.String({ minLength: 1 }), PropertyMetadata), + }), + }), +}) + +export type DeviceShadowType = Static diff --git a/nrfcloud/createAccountDevice.ts b/nrfcloud/createAccountDevice.ts index e7ac3c2ab..70541292b 100644 --- a/nrfcloud/createAccountDevice.ts +++ b/nrfcloud/createAccountDevice.ts @@ -1,9 +1,13 @@ -import { slashless } from '../util/slashless.js' +import { Type, type Static } from '@sinclair/typebox' +import { validatedFetch } from './validatedFetch.js' -export type CertificateCredentials = { - clientCert: string - privateKey: string -} +/** + * @link https://api.nrfcloud.com/v1/#tag/Account-Devices/operation/CreateAccountDevice + */ +const CertificateCredentials = Type.Object({ + clientCert: Type.String(), + privateKey: Type.String(), +}) export const createAccountDevice = async ({ apiKey, @@ -11,19 +15,19 @@ export const createAccountDevice = async ({ }: { apiKey: string endpoint: URL -}): Promise => { - const accountDevice = await // FIXME: validate response - ( - await fetch(`${slashless(endpoint)}/v1/devices/account`, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - ).json() +}): Promise> => { + const vf = validatedFetch({ endpoint, apiKey }) + const maybeResult = await vf( + { resource: 'devices/account', method: 'POST' }, + CertificateCredentials, + ) + + if ('error' in maybeResult) { + throw maybeResult.error + } return { - clientCert: accountDevice.clientCert, - privateKey: accountDevice.privateKey, + clientCert: maybeResult.result.clientCert, + privateKey: maybeResult.result.privateKey, } } diff --git a/nrfcloud/deviceShadowRepo.spec.ts b/nrfcloud/deviceShadowRepo.spec.ts index 9685520d8..8dcfe0798 100644 --- a/nrfcloud/deviceShadowRepo.spec.ts +++ b/nrfcloud/deviceShadowRepo.spec.ts @@ -1,11 +1,11 @@ import type { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { store, get } from './deviceShadowRepo.js' -import type { DeviceShadow } from './DeviceShadow' +import type { DeviceShadowType } from './DeviceShadow' import { check, objectMatching } from 'tsmatchers' import { marshall } from '@aws-sdk/util-dynamodb' import { ResourceNotFoundException } from '@aws-sdk/client-iot' -const shadow: DeviceShadow = { +const shadow: DeviceShadowType = { id: 'someId', tags: ['configuration:solar-shield', 'model:PCA20035'], tenantId: 'a0673464-e4e1-4b87-bffd-6941a012067b', diff --git a/nrfcloud/deviceShadowRepo.ts b/nrfcloud/deviceShadowRepo.ts index aa3a04a96..02378851e 100644 --- a/nrfcloud/deviceShadowRepo.ts +++ b/nrfcloud/deviceShadowRepo.ts @@ -4,7 +4,7 @@ import { type DynamoDBClient, } from '@aws-sdk/client-dynamodb' import { marshall, unmarshall } from '@aws-sdk/util-dynamodb' -import type { DeviceShadow } from './DeviceShadow' +import type { DeviceShadowType } from './DeviceShadow' /** * Store the updated shadow in DynamoDB for sending it right after a client connects @@ -16,7 +16,7 @@ export const store = }: { db: DynamoDBClient TableName: string - }): ((shadow: DeviceShadow) => Promise) => + }): ((shadow: DeviceShadowType) => Promise) => async (shadow) => { await db.send( new PutItemCommand({ @@ -38,7 +38,7 @@ export const get = }: { db: DynamoDBClient TableName: string - }): ((deviceId: string) => Promise<{ shadow: DeviceShadow | null }>) => + }): ((deviceId: string) => Promise<{ shadow: DeviceShadowType | null }>) => async (deviceId) => { try { const { Item } = await db.send( @@ -52,7 +52,7 @@ export const get = }), ) const { shadow } = unmarshall(Item as Record) as { - shadow: DeviceShadow + shadow: DeviceShadowType } return { shadow } } catch { diff --git a/nrfcloud/devices.ts b/nrfcloud/devices.ts index 18b03ffa7..cdce10f90 100644 --- a/nrfcloud/devices.ts +++ b/nrfcloud/devices.ts @@ -84,6 +84,13 @@ const Page = (Item: T) => }) const Devices = Page(Device) +/** + * @link https://api.nrfcloud.com/v1/#tag/IP-Devices/operation/ProvisionDevices + */ +const ProvisionDevice = Type.Object({ + bulkOpsRequestId: Type.String(), +}) + type FwType = | 'APP' | 'MODEM' @@ -182,40 +189,24 @@ export const devices = ( .map((cols) => cols.join(',')) .join('\n') - // FIXME: validate response - const registrationResult = await fetch( - `${slashless(endpoint)}/v1/devices`, + const maybeResult = await vf( { - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/octet-stream', + resource: 'devices', + payload: { + body: bulkRegistrationPayload, + type: 'application/octet-stream', }, - method: 'POST', - body: bulkRegistrationPayload, }, + ProvisionDevice, ) - if (registrationResult.ok !== true) { + if ('error' in maybeResult) { return { - error: new Error( - `${registrationResult.statusText} (${registrationResult.status})`, - ), + error: maybeResult.error, } } - const res = await registrationResult.json() - - if ('bulkOpsRequestId' in res) - return { bulkOpsRequestId: res.bulkOpsRequestId } - - if ('code' in res && 'message' in res) - return { - error: new Error( - `${res.message} (${res.code}): ${JSON.stringify(res)}`, - ), - } - - return { error: new Error(`Import failed: ${JSON.stringify(res)}`) } + return { bulkOpsRequestId: maybeResult.result.bulkOpsRequestId } }, } } diff --git a/nrfcloud/getDeviceShadowFromnRFCloud.ts b/nrfcloud/getDeviceShadowFromnRFCloud.ts index 67925ab1f..855cc94dc 100644 --- a/nrfcloud/getDeviceShadowFromnRFCloud.ts +++ b/nrfcloud/getDeviceShadowFromnRFCloud.ts @@ -1,20 +1,30 @@ +import { Type, type Static } from '@sinclair/typebox' import { logger } from '../lambda/util/logger.js' -import { slashless } from '../util/slashless.js' -import { type DeviceShadow } from './DeviceShadow.js' +import { validatedFetch } from './validatedFetch.js' +import { DeviceShadow } from './DeviceShadow.js' + +const DeviceShadows = Type.Array(DeviceShadow) + +const ListDevices = Type.Object({ + items: DeviceShadows, + total: Type.Number(), + pageNextToken: Type.String(), +}) const log = logger('deviceShadowFetcher') -export const deviceShadowFetcher = - ({ - endpoint, - apiKey, - onError, - }: { - endpoint: URL - apiKey: string - onError?: (res: Response) => void - }) => - async (devices: string[]): Promise => { +export const deviceShadowFetcher = ({ + endpoint, + apiKey, + onError, +}: { + endpoint: URL + apiKey: string + onError?: (error: Error) => void +}): ((devices: string[]) => Promise>) => { + const vf = validatedFetch({ endpoint, apiKey }) + + return async (devices) => { const params = { includeState: true, includeStateMeta: true, @@ -25,27 +35,16 @@ export const deviceShadowFetcher = .sort((a, b) => a[0].localeCompare(b[0])) .map((kv) => kv.map(encodeURIComponent).join('=')) .join('&') - const url = `${slashless(endpoint)}/v1/devices?${queryString}` + const url = `devices?${queryString}` log.info(`Fetching device shadow`, { url }) - // Change to bulk fetching device shadow otherwise it might hit rate limit - // FIXME: validate response - const res = await fetch(url, { - method: 'get', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (res.ok) { - const data = await res.json() - return data.items as DeviceShadow[] - } else { - onError?.(res) - log.error(`Fetching shadow error`, { - status: res.status, - statusText: res.statusText, - }) + const maybeResult = await vf({ resource: url }, ListDevices) + if ('error' in maybeResult) { + onError?.(maybeResult.error) + log.error(`Fetching shadow error`, { error: maybeResult.error }) return [] } + + return maybeResult.result.items } +} diff --git a/nrfcloud/validatedFetch.spec.ts b/nrfcloud/validatedFetch.spec.ts index 95081ae24..beba60fec 100644 --- a/nrfcloud/validatedFetch.spec.ts +++ b/nrfcloud/validatedFetch.spec.ts @@ -1,5 +1,5 @@ import { Type } from '@sinclair/typebox' -import { validatedFetch } from './validatedFetch.js' +import { JSONPayload, validatedFetch } from './validatedFetch.js' describe('validatedFetch()', () => { it('should call an nRF Cloud API endpoint and validate the response', async () => { @@ -29,7 +29,6 @@ describe('validatedFetch()', () => { headers: { Accept: 'application/json; charset=utf-8', Authorization: 'Bearer some-key', - 'Content-Type': 'application/json', }, }) }) @@ -50,4 +49,83 @@ describe('validatedFetch()', () => { await vf({ resource: 'some-resource' }, mockFetch as any), ).toMatchObject({ error: err }) }) + + it('should send POST request if body is given', async () => { + const mockFetch = jest.fn(() => ({ + ok: true, + json: async () => Promise.resolve({}), + })) + const vf = validatedFetch( + { + endpoint: new URL('https://example.com/'), + apiKey: 'some-key', + }, + mockFetch as any, + ) + + await vf( + { + resource: 'foo', + payload: { + type: 'application/octet-stream', + body: 'some data', + }, + }, + Type.Object({}), + ) + + expect(mockFetch).toHaveBeenCalledWith( + `https://example.com/v1/foo`, + expect.objectContaining({ + method: 'POST', + body: 'some data', + headers: { + Accept: 'application/json; charset=utf-8', + Authorization: 'Bearer some-key', + 'Content-Type': 'application/octet-stream', + }, + }), + ) + }) + + it('should allow to specify the method', async () => { + const mockFetch = jest.fn(() => ({ + ok: true, + json: async () => Promise.resolve({}), + })) + const vf = validatedFetch( + { + endpoint: new URL('https://example.com/'), + apiKey: 'some-key', + }, + mockFetch as any, + ) + + await vf( + { + resource: 'foo', + method: 'POST', + }, + Type.Object({}), + ) + + expect(mockFetch).toHaveBeenCalledWith( + `https://example.com/v1/foo`, + expect.objectContaining({ + method: 'POST', + headers: { + Accept: 'application/json; charset=utf-8', + Authorization: 'Bearer some-key', + }, + }), + ) + }) +}) + +describe('JSONPayload()', () => { + it('should convert a an object to a payload definition to be used in validatedFecth', () => + expect(JSONPayload({ foo: 'bar' })).toEqual({ + type: 'application/json', + body: JSON.stringify({ foo: 'bar' }), + })) }) diff --git a/nrfcloud/validatedFetch.ts b/nrfcloud/validatedFetch.ts index 7c7b33e12..8c870dbed 100644 --- a/nrfcloud/validatedFetch.ts +++ b/nrfcloud/validatedFetch.ts @@ -20,6 +20,7 @@ const validate = ( const maybeData = validateWithTypeBox(SchemaObject)(data) if ('errors' in maybeData) { + console.error('Validation failed', { error: maybeData.errors }) throw new ValidationError(maybeData.errors) } @@ -42,21 +43,55 @@ export const validatedFetch = fetchImplementation?: typeof fetch, ) => async ( - { resource }: { resource: string }, + params: + | { + resource: string + } + | { + resource: string + payload: Payload + } + | { + resource: string + method: string + }, schema: Schema, - ): Promise<{ error: Error | ValidationError } | { result: Static }> => - fetchData(fetchImplementation)(`${slashless(endpoint)}/v1/${resource}`, { - headers: { - ...headers(apiKey), - 'Content-Type': 'application/json', - }, - }) + ): Promise< + { error: Error | ValidationError } | { result: Static } + > => { + const { resource } = params + const args: Parameters[1] = { + headers: headers(apiKey), + } + if ('payload' in params) { + const payload = params.payload + args.method = 'POST' + args.body = payload.body + args.headers = { ...(args.headers ?? {}), ['Content-Type']: payload.type } + } else if ('method' in params) { + args.method = params.method + } + return fetchData(fetchImplementation)( + `${slashless(endpoint)}/v1/${resource}`, + args, + ) .then((res) => ({ result: validate(schema, res) })) .catch((error: Error): { error: Error | ValidationError } => ({ error, })) + } const headers = (apiKey: string) => ({ Authorization: `Bearer ${apiKey}`, Accept: 'application/json; charset=utf-8', }) + +type Payload = { + /** The content-type of body */ + type: string + body: string +} +export const JSONPayload = (payload: Record): Payload => ({ + type: 'application/json', + body: JSON.stringify(payload), +})