Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lambda/loggingFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch> => {
async (
url: URL | RequestInfo,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required to allow loggingFetch to be able to pass as fetchImplementation

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've addressed this in a refactor.

init?: RequestInit,
): ReturnType<typeof fetch> => {
log.debug(`fetch:url`, url.toString())
if (init?.body !== null && init?.body !== undefined)
log.debug(`fetch:body`, init.body.toString())
Expand Down
97 changes: 42 additions & 55 deletions lambda/resolveSingleCellGeoLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -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')
Expand All @@ -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
},
)

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions lambda/ws/sendShadowToConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -26,7 +26,7 @@ export const sendShadowToConnection =
connectionId,
shadow,
}: {
shadow: DeviceShadow
shadow: DeviceShadowType
model: string
connectionId: string
}): Promise<void> => {
Expand Down
52 changes: 52 additions & 0 deletions nrfcloud/DeviceShadow.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
30 changes: 22 additions & 8 deletions nrfcloud/DeviceShadow.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
export type DeviceShadow = {
id: string
state: {
reported: Record<string, any>
version: number
metadata: Record<string, any>
}
}
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<typeof DeviceShadow>
38 changes: 21 additions & 17 deletions nrfcloud/createAccountDevice.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
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,
endpoint,
}: {
apiKey: string
endpoint: URL
}): Promise<CertificateCredentials> => {
const accountDevice = await // FIXME: validate response
(
await fetch(`${slashless(endpoint)}/v1/devices/account`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
).json()
}): Promise<Static<typeof CertificateCredentials>> => {
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,
}
}
4 changes: 2 additions & 2 deletions nrfcloud/deviceShadowRepo.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
8 changes: 4 additions & 4 deletions nrfcloud/deviceShadowRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,7 +16,7 @@ export const store =
}: {
db: DynamoDBClient
TableName: string
}): ((shadow: DeviceShadow) => Promise<void>) =>
}): ((shadow: DeviceShadowType) => Promise<void>) =>
async (shadow) => {
await db.send(
new PutItemCommand({
Expand All @@ -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(
Expand All @@ -52,7 +52,7 @@ export const get =
}),
)
const { shadow } = unmarshall(Item as Record<string, never>) as {
shadow: DeviceShadow
shadow: DeviceShadowType
}
return { shadow }
} catch {
Expand Down
Loading