Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add snapshot creation to Stable API #1373

Merged
merged 1 commit into from
Feb 3, 2025
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
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
}

Check warning on line 21 in meteor/server/api/rest/v1/idempotencyService.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/idempotencyService.ts#L21

Added line #L21 was not covered by tests
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 registerStudiosRoutes } from './studios'
import { registerRoutes as registerSystemRoutes } from './system'
import { registerRoutes as registerBucketsRoutes } from './buckets'
import { registerRoutes as registerSnapshotRoutes } from './snapshots'

Check warning on line 21 in meteor/server/api/rest/v1/index.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/index.ts#L21

Added line #L21 was not covered by tests
import { APIFactory, ServerAPIContext } from './types'

function restAPIUserEvent(
Expand Down Expand Up @@ -199,3 +200,4 @@
registerStudiosRoutes(sofieAPIRequest)
registerSystemRoutes(sofieAPIRequest)
registerBucketsRoutes(sofieAPIRequest)
registerSnapshotRoutes(sofieAPIRequest)

Check warning on line 203 in meteor/server/api/rest/v1/index.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/index.ts#L203

Added line #L203 was not covered by tests
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)
}
}

Check warning on line 34 in meteor/server/api/rest/v1/middlewares.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/middlewares.ts#L2-L34

Added lines #L2 - L34 were not covered by tests
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
)
)
}

Check warning on line 82 in meteor/server/api/rest/v1/snapshots.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/snapshots.ts#L2-L82

Added lines #L2 - L82 were not covered by tests
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 @@
APISourceLayer,
APIStudio,
APIStudioSettings,
APIPlaylistSnapshotOptions,
APISystemSnapshotOptions,

Check warning on line 44 in meteor/server/api/rest/v1/typeConversion.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/typeConversion.ts#L43-L44

Added lines #L43 - L44 were not covered by tests
} 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 @@
} 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'

Check warning on line 59 in meteor/server/api/rest/v1/typeConversion.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/typeConversion.ts#L59

Added line #L59 was not covered by tests

/*
This file contains functions that convert between the internal Sofie-Core types and types exposed to the external API.
Expand Down Expand Up @@ -689,3 +692,18 @@
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,
}
}

Check warning on line 709 in meteor/server/api/rest/v1/typeConversion.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/typeConversion.ts#L695-L709

Added lines #L695 - L709 were not covered by tests
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 { 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>>

Check warning on line 13 in meteor/server/api/rest/v1/types.ts

View check run for this annotation

Codecov / codecov/patch

meteor/server/api/rest/v1/types.ts#L6-L13

Added lines #L6 - L13 were not covered by tests
export type APIRegisterHook<T> = <Params, Body, Response>(
method: 'get' | 'post' | 'put' | 'delete',
route: string,
Expand Down
Loading
Loading