Skip to content

Commit

Permalink
Merge pull request #1373 from tv2norge-collab/contribute/EAV-168
Browse files Browse the repository at this point in the history
feat: add snapshot creation to Stable API
  • Loading branch information
nytamin authored Feb 3, 2025
2 parents 063021b + 806b0db commit 7ba5ede
Show file tree
Hide file tree
Showing 23 changed files with 645 additions and 62 deletions.
45 changes: 45 additions & 0 deletions meteor/server/api/rest/v1/__tests__/idempotencyService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { IdempotencyService } from '../idempotencyService'

describe('IdempotencyService', () => {
let idempotencyService: IdempotencyService

beforeEach(() => {
jest.useFakeTimers()
idempotencyService = new IdempotencyService(60 * 5 * 1000, 60 * 1000)
})

afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})

it('should allow unique requests within the idempotency period', () => {
const requestId = 'unique-request-id'

expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(true)

const requestId2 = 'another-unique-request-id'

expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId2)).toBe(true)
})

it('should disallow duplicate requests within the idempotency period', () => {
const requestId = 'duplicate-request-id'

expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(true)

expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(false)
})

it('should allow duplicate requests after the idempotency period', async () => {
const requestId = 'unique-request-id'

expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(true)

jest.advanceTimersByTime(55 * 5 * 1000)
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(false)

jest.advanceTimersByTime(5 * 5 * 1000 + 1)
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(true)
})
})
32 changes: 32 additions & 0 deletions meteor/server/api/rest/v1/__tests__/rateLimitingService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { RateLimitingService } from '../rateLimitingService'

describe('RateLimitingService', () => {
let rateLimitingService: RateLimitingService
const throttlingPeriodMs = 1000 // 1 second

beforeEach(() => {
jest.useFakeTimers()
rateLimitingService = new RateLimitingService(throttlingPeriodMs)
})

afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})

test('allows access if no recent access', () => {
expect(rateLimitingService.isAllowedToAccess('resource')).toBe(true)
expect(rateLimitingService.isAllowedToAccess('anotherResource')).toBe(true)
})

test('denies access if accessed within throttling period', () => {
rateLimitingService.isAllowedToAccess('resource')
expect(rateLimitingService.isAllowedToAccess('resource')).toBe(false)
})

test('allows access after throttling period', () => {
rateLimitingService.isAllowedToAccess('resource')
jest.advanceTimersByTime(throttlingPeriodMs + 1)
expect(rateLimitingService.isAllowedToAccess('resource')).toBe(true)
})
})
41 changes: 41 additions & 0 deletions meteor/server/api/rest/v1/idempotencyService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export class IdempotencyService {
private requestRecords: Map<string, number> = new Map()

constructor(private idempotencyPeriodMs: number, private cleanupIntervalMs: number) {
this.scheduleCleanup()
}

private scheduleCleanup() {
setInterval(this.cleanupExpiredRecords.bind(this), this.cleanupIntervalMs)
}

isUniqueWithinIdempotencyPeriod(requestId: string): boolean {
const currentTime = this.getCurrentTime()
const requestTimestamp = this.requestRecords.get(requestId)

if (requestTimestamp !== undefined) {
if (currentTime - requestTimestamp <= this.idempotencyPeriodMs) {
return false
}
this.requestRecords.delete(requestId) // so that the entry is reinserted at the end
}
this.requestRecords.set(requestId, currentTime)
return true
}

private cleanupExpiredRecords(): void {
const currentTime = this.getCurrentTime()
for (const [requestId, requestTimestamp] of this.requestRecords.entries()) {
if (currentTime - requestTimestamp < this.idempotencyPeriodMs) {
break // because the entries are in insertion order
}
this.requestRecords.delete(requestId)
}
}

private getCurrentTime() {
return Date.now()
}
}

export default new IdempotencyService(60 * 5 * 1000, 60 * 1000)
2 changes: 2 additions & 0 deletions meteor/server/api/rest/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { registerRoutes as registerShowStylesRoutes } from './showstyles'
import { registerRoutes as registerStudiosRoutes } from './studios'
import { registerRoutes as registerSystemRoutes } from './system'
import { registerRoutes as registerBucketsRoutes } from './buckets'
import { registerRoutes as registerSnapshotRoutes } from './snapshots'
import { APIFactory, ServerAPIContext } from './types'

function restAPIUserEvent(
Expand Down Expand Up @@ -199,3 +200,4 @@ registerShowStylesRoutes(sofieAPIRequest)
registerStudiosRoutes(sofieAPIRequest)
registerSystemRoutes(sofieAPIRequest)
registerBucketsRoutes(sofieAPIRequest)
registerSnapshotRoutes(sofieAPIRequest)
34 changes: 34 additions & 0 deletions meteor/server/api/rest/v1/middlewares.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// not really middlewares

import { Meteor } from 'meteor/meteor'
import { APIHandler } from './types'
import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error'
import idempotencyService from './idempotencyService'
import rateLimitingService from './rateLimitingService'

export function makeIdempotent<T, Params, Body, Response>(
handler: APIHandler<T, Params, Body, Response>
): APIHandler<T, Params, Body, Response> {
return async (serverAPI: T, connection: Meteor.Connection, event: string, params: Params, body: Body) => {
const idempotencyKey = connection.httpHeaders['idempotency-key']
if (typeof idempotencyKey !== 'string' || idempotencyKey.length <= 0) {
throw UserError.create(UserErrorMessage.IdempotencyKeyMissing, undefined, 400)
}
if (!idempotencyService.isUniqueWithinIdempotencyPeriod(idempotencyKey)) {
throw UserError.create(UserErrorMessage.IdempotencyKeyAlreadyUsed, undefined, 422)
}
return await handler(serverAPI, connection, event, params, body)
}
}

export function makeRateLimited<T, Params, Body, Response>(
handler: APIHandler<T, Params, Body, Response>,
resourceName: string
): APIHandler<T, Params, Body, Response> {
return async (serverAPI: T, connection: Meteor.Connection, event: string, params: Params, body: Body) => {
if (!rateLimitingService.isAllowedToAccess(resourceName)) {
throw UserError.create(UserErrorMessage.RateLimitExceeded, undefined, 429)
}
return await handler(serverAPI, connection, event, params, body)
}
}
21 changes: 21 additions & 0 deletions meteor/server/api/rest/v1/rateLimitingService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export class RateLimitingService {
private resourceRecords: Map<string, number> = new Map()

constructor(private throttlingPeriodMs: number) {}

isAllowedToAccess(resourceName: string): boolean | number {
const currentTime = this.getCurrentTime()
const requestTimestamp = this.resourceRecords.get(resourceName)
if (requestTimestamp !== undefined && currentTime - requestTimestamp <= this.throttlingPeriodMs) {
return false
}
this.resourceRecords.set(resourceName, currentTime)
return true
}

private getCurrentTime() {
return Date.now()
}
}

export default new RateLimitingService(1 * 1000)
82 changes: 82 additions & 0 deletions meteor/server/api/rest/v1/snapshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Meteor } from 'meteor/meteor'
import { SnapshotId } from '@sofie-automation/corelib/dist/dataModel/Ids'
import { check } from 'meteor/check'
import { APIFactory, APIRegisterHook, ServerAPIContext } from './types'
import { logger } from '../../../logging'
import { storeRundownPlaylistSnapshot, storeSystemSnapshot } from '../../snapshot'
import { makeIdempotent, makeRateLimited } from './middlewares'
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
import { playlistSnapshotOptionsFrom, systemSnapshotOptionsFrom } from './typeConversion'
import {
APIPlaylistSnapshotOptions,
APISnapshotType,
APISystemSnapshotOptions,
SnapshotsRestAPI,
} from '../../../lib/rest/v1'
import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client'
import { checkAccessToPlaylist } from '../../../security/check'

export class SnapshotsServerAPI implements SnapshotsRestAPI {
constructor(private context: ServerAPIContext) {}

async storeSystemSnapshot(
connection: Meteor.Connection,
_event: string,
options: APISystemSnapshotOptions
): Promise<ClientAPI.ClientResponse<SnapshotId>> {
check(options.reason, String)
return ClientAPI.responseSuccess(
await storeSystemSnapshot(
this.context.getMethodContext(connection),
systemSnapshotOptionsFrom(options),
options.reason
)
)
}

async storePlaylistSnapshot(
connection: Meteor.Connection,
_event: string,
options: APIPlaylistSnapshotOptions
): Promise<ClientAPI.ClientResponse<SnapshotId>> {
const playlistId = protectString(options.rundownPlaylistId)
check(playlistId, String)
check(options.reason, String)
const access = await checkAccessToPlaylist(connection, playlistId)
return ClientAPI.responseSuccess(
await storeRundownPlaylistSnapshot(access, playlistSnapshotOptionsFrom(options), options.reason)
)
}
}

class SnapshotsAPIFactory implements APIFactory<SnapshotsRestAPI> {
createServerAPI(context: ServerAPIContext): SnapshotsRestAPI {
return new SnapshotsServerAPI(context)
}
}

const SNAPSHOT_RESOURCE = 'snapshot'

export function registerRoutes(registerRoute: APIRegisterHook<SnapshotsRestAPI>): void {
const snapshotsApiFactory = new SnapshotsAPIFactory()

registerRoute<never, APISystemSnapshotOptions | APIPlaylistSnapshotOptions, SnapshotId>(
'post',
'/snapshots',
new Map(),
snapshotsApiFactory,
makeRateLimited(
makeIdempotent(async (serverAPI, connection, event, _params, body) => {
if (body.snapshotType === APISnapshotType.SYSTEM) {
logger.info(`API POST: Store System Snapshot`)
return await serverAPI.storeSystemSnapshot(connection, event, body)
} else if (body.snapshotType === APISnapshotType.PLAYLIST) {
logger.info(`API POST: Store Playlist Snapshot`)
return await serverAPI.storePlaylistSnapshot(connection, event, body)
}
throw new Meteor.Error(400, `Invalid snapshot type`)
}),
SNAPSHOT_RESOURCE
)
)
}
18 changes: 18 additions & 0 deletions meteor/server/api/rest/v1/typeConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
APISourceLayer,
APIStudio,
APIStudioSettings,
APIPlaylistSnapshotOptions,
APISystemSnapshotOptions,
} from '../../../lib/rest/v1'
import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase'
import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant'
Expand All @@ -54,6 +56,7 @@ import {
} from '@sofie-automation/shared-lib/dist/core/constants'
import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets'
import { ForceQuickLoopAutoNext } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings'
import { PlaylistSnapshotOptions, SystemSnapshotOptions } from '@sofie-automation/meteor-lib/dist/api/shapshot'

/*
This file contains functions that convert between the internal Sofie-Core types and types exposed to the external API.
Expand Down Expand Up @@ -701,3 +704,18 @@ export function APIBucketFrom(bucket: Bucket): APIBucketComplete {
studioId: unprotectString(bucket.studioId),
}
}

export function systemSnapshotOptionsFrom(options: APISystemSnapshotOptions): SystemSnapshotOptions {
return {
withDeviceSnapshots: !!options.withDeviceSnapshots,
studioId: typeof options.studioId === 'string' ? protectString(options.studioId) : undefined,
}
}

export function playlistSnapshotOptionsFrom(options: APIPlaylistSnapshotOptions): PlaylistSnapshotOptions {
return {
withDeviceSnapshots: !!options.withDeviceSnapshots,
withArchivedDocuments: !!options.withArchivedDocuments,
withTimeline: !!options.withTimeline,
}
}
8 changes: 8 additions & 0 deletions meteor/server/api/rest/v1/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { Meteor } from 'meteor/meteor'
import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client'
import { MethodContextAPI } from '../../methodContext'

export type APIHandler<T, Params, Body, Response> = (
serverAPI: T,
connection: Meteor.Connection,
event: string,
params: Params,
body: Body
) => Promise<ClientAPI.ClientResponse<Response>>

export type APIRegisterHook<T> = <Params, Body, Response>(
method: 'get' | 'post' | 'put' | 'delete',
route: string,
Expand Down
Loading

0 comments on commit 7ba5ede

Please sign in to comment.