Skip to content

Commit 7ba5ede

Browse files
authored
Merge pull request #1373 from tv2norge-collab/contribute/EAV-168
feat: add snapshot creation to Stable API
2 parents 063021b + 806b0db commit 7ba5ede

File tree

23 files changed

+645
-62
lines changed

23 files changed

+645
-62
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { IdempotencyService } from '../idempotencyService'
2+
3+
describe('IdempotencyService', () => {
4+
let idempotencyService: IdempotencyService
5+
6+
beforeEach(() => {
7+
jest.useFakeTimers()
8+
idempotencyService = new IdempotencyService(60 * 5 * 1000, 60 * 1000)
9+
})
10+
11+
afterEach(() => {
12+
jest.clearAllTimers()
13+
jest.useRealTimers()
14+
})
15+
16+
it('should allow unique requests within the idempotency period', () => {
17+
const requestId = 'unique-request-id'
18+
19+
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(true)
20+
21+
const requestId2 = 'another-unique-request-id'
22+
23+
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId2)).toBe(true)
24+
})
25+
26+
it('should disallow duplicate requests within the idempotency period', () => {
27+
const requestId = 'duplicate-request-id'
28+
29+
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(true)
30+
31+
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(false)
32+
})
33+
34+
it('should allow duplicate requests after the idempotency period', async () => {
35+
const requestId = 'unique-request-id'
36+
37+
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(true)
38+
39+
jest.advanceTimersByTime(55 * 5 * 1000)
40+
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(false)
41+
42+
jest.advanceTimersByTime(5 * 5 * 1000 + 1)
43+
expect(idempotencyService.isUniqueWithinIdempotencyPeriod(requestId)).toBe(true)
44+
})
45+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { RateLimitingService } from '../rateLimitingService'
2+
3+
describe('RateLimitingService', () => {
4+
let rateLimitingService: RateLimitingService
5+
const throttlingPeriodMs = 1000 // 1 second
6+
7+
beforeEach(() => {
8+
jest.useFakeTimers()
9+
rateLimitingService = new RateLimitingService(throttlingPeriodMs)
10+
})
11+
12+
afterEach(() => {
13+
jest.clearAllTimers()
14+
jest.useRealTimers()
15+
})
16+
17+
test('allows access if no recent access', () => {
18+
expect(rateLimitingService.isAllowedToAccess('resource')).toBe(true)
19+
expect(rateLimitingService.isAllowedToAccess('anotherResource')).toBe(true)
20+
})
21+
22+
test('denies access if accessed within throttling period', () => {
23+
rateLimitingService.isAllowedToAccess('resource')
24+
expect(rateLimitingService.isAllowedToAccess('resource')).toBe(false)
25+
})
26+
27+
test('allows access after throttling period', () => {
28+
rateLimitingService.isAllowedToAccess('resource')
29+
jest.advanceTimersByTime(throttlingPeriodMs + 1)
30+
expect(rateLimitingService.isAllowedToAccess('resource')).toBe(true)
31+
})
32+
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export class IdempotencyService {
2+
private requestRecords: Map<string, number> = new Map()
3+
4+
constructor(private idempotencyPeriodMs: number, private cleanupIntervalMs: number) {
5+
this.scheduleCleanup()
6+
}
7+
8+
private scheduleCleanup() {
9+
setInterval(this.cleanupExpiredRecords.bind(this), this.cleanupIntervalMs)
10+
}
11+
12+
isUniqueWithinIdempotencyPeriod(requestId: string): boolean {
13+
const currentTime = this.getCurrentTime()
14+
const requestTimestamp = this.requestRecords.get(requestId)
15+
16+
if (requestTimestamp !== undefined) {
17+
if (currentTime - requestTimestamp <= this.idempotencyPeriodMs) {
18+
return false
19+
}
20+
this.requestRecords.delete(requestId) // so that the entry is reinserted at the end
21+
}
22+
this.requestRecords.set(requestId, currentTime)
23+
return true
24+
}
25+
26+
private cleanupExpiredRecords(): void {
27+
const currentTime = this.getCurrentTime()
28+
for (const [requestId, requestTimestamp] of this.requestRecords.entries()) {
29+
if (currentTime - requestTimestamp < this.idempotencyPeriodMs) {
30+
break // because the entries are in insertion order
31+
}
32+
this.requestRecords.delete(requestId)
33+
}
34+
}
35+
36+
private getCurrentTime() {
37+
return Date.now()
38+
}
39+
}
40+
41+
export default new IdempotencyService(60 * 5 * 1000, 60 * 1000)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { registerRoutes as registerShowStylesRoutes } from './showstyles'
1818
import { registerRoutes as registerStudiosRoutes } from './studios'
1919
import { registerRoutes as registerSystemRoutes } from './system'
2020
import { registerRoutes as registerBucketsRoutes } from './buckets'
21+
import { registerRoutes as registerSnapshotRoutes } from './snapshots'
2122
import { APIFactory, ServerAPIContext } from './types'
2223

2324
function restAPIUserEvent(
@@ -199,3 +200,4 @@ registerShowStylesRoutes(sofieAPIRequest)
199200
registerStudiosRoutes(sofieAPIRequest)
200201
registerSystemRoutes(sofieAPIRequest)
201202
registerBucketsRoutes(sofieAPIRequest)
203+
registerSnapshotRoutes(sofieAPIRequest)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// not really middlewares
2+
3+
import { Meteor } from 'meteor/meteor'
4+
import { APIHandler } from './types'
5+
import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error'
6+
import idempotencyService from './idempotencyService'
7+
import rateLimitingService from './rateLimitingService'
8+
9+
export function makeIdempotent<T, Params, Body, Response>(
10+
handler: APIHandler<T, Params, Body, Response>
11+
): APIHandler<T, Params, Body, Response> {
12+
return async (serverAPI: T, connection: Meteor.Connection, event: string, params: Params, body: Body) => {
13+
const idempotencyKey = connection.httpHeaders['idempotency-key']
14+
if (typeof idempotencyKey !== 'string' || idempotencyKey.length <= 0) {
15+
throw UserError.create(UserErrorMessage.IdempotencyKeyMissing, undefined, 400)
16+
}
17+
if (!idempotencyService.isUniqueWithinIdempotencyPeriod(idempotencyKey)) {
18+
throw UserError.create(UserErrorMessage.IdempotencyKeyAlreadyUsed, undefined, 422)
19+
}
20+
return await handler(serverAPI, connection, event, params, body)
21+
}
22+
}
23+
24+
export function makeRateLimited<T, Params, Body, Response>(
25+
handler: APIHandler<T, Params, Body, Response>,
26+
resourceName: string
27+
): APIHandler<T, Params, Body, Response> {
28+
return async (serverAPI: T, connection: Meteor.Connection, event: string, params: Params, body: Body) => {
29+
if (!rateLimitingService.isAllowedToAccess(resourceName)) {
30+
throw UserError.create(UserErrorMessage.RateLimitExceeded, undefined, 429)
31+
}
32+
return await handler(serverAPI, connection, event, params, body)
33+
}
34+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export class RateLimitingService {
2+
private resourceRecords: Map<string, number> = new Map()
3+
4+
constructor(private throttlingPeriodMs: number) {}
5+
6+
isAllowedToAccess(resourceName: string): boolean | number {
7+
const currentTime = this.getCurrentTime()
8+
const requestTimestamp = this.resourceRecords.get(resourceName)
9+
if (requestTimestamp !== undefined && currentTime - requestTimestamp <= this.throttlingPeriodMs) {
10+
return false
11+
}
12+
this.resourceRecords.set(resourceName, currentTime)
13+
return true
14+
}
15+
16+
private getCurrentTime() {
17+
return Date.now()
18+
}
19+
}
20+
21+
export default new RateLimitingService(1 * 1000)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Meteor } from 'meteor/meteor'
2+
import { SnapshotId } from '@sofie-automation/corelib/dist/dataModel/Ids'
3+
import { check } from 'meteor/check'
4+
import { APIFactory, APIRegisterHook, ServerAPIContext } from './types'
5+
import { logger } from '../../../logging'
6+
import { storeRundownPlaylistSnapshot, storeSystemSnapshot } from '../../snapshot'
7+
import { makeIdempotent, makeRateLimited } from './middlewares'
8+
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
9+
import { playlistSnapshotOptionsFrom, systemSnapshotOptionsFrom } from './typeConversion'
10+
import {
11+
APIPlaylistSnapshotOptions,
12+
APISnapshotType,
13+
APISystemSnapshotOptions,
14+
SnapshotsRestAPI,
15+
} from '../../../lib/rest/v1'
16+
import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client'
17+
import { checkAccessToPlaylist } from '../../../security/check'
18+
19+
export class SnapshotsServerAPI implements SnapshotsRestAPI {
20+
constructor(private context: ServerAPIContext) {}
21+
22+
async storeSystemSnapshot(
23+
connection: Meteor.Connection,
24+
_event: string,
25+
options: APISystemSnapshotOptions
26+
): Promise<ClientAPI.ClientResponse<SnapshotId>> {
27+
check(options.reason, String)
28+
return ClientAPI.responseSuccess(
29+
await storeSystemSnapshot(
30+
this.context.getMethodContext(connection),
31+
systemSnapshotOptionsFrom(options),
32+
options.reason
33+
)
34+
)
35+
}
36+
37+
async storePlaylistSnapshot(
38+
connection: Meteor.Connection,
39+
_event: string,
40+
options: APIPlaylistSnapshotOptions
41+
): Promise<ClientAPI.ClientResponse<SnapshotId>> {
42+
const playlistId = protectString(options.rundownPlaylistId)
43+
check(playlistId, String)
44+
check(options.reason, String)
45+
const access = await checkAccessToPlaylist(connection, playlistId)
46+
return ClientAPI.responseSuccess(
47+
await storeRundownPlaylistSnapshot(access, playlistSnapshotOptionsFrom(options), options.reason)
48+
)
49+
}
50+
}
51+
52+
class SnapshotsAPIFactory implements APIFactory<SnapshotsRestAPI> {
53+
createServerAPI(context: ServerAPIContext): SnapshotsRestAPI {
54+
return new SnapshotsServerAPI(context)
55+
}
56+
}
57+
58+
const SNAPSHOT_RESOURCE = 'snapshot'
59+
60+
export function registerRoutes(registerRoute: APIRegisterHook<SnapshotsRestAPI>): void {
61+
const snapshotsApiFactory = new SnapshotsAPIFactory()
62+
63+
registerRoute<never, APISystemSnapshotOptions | APIPlaylistSnapshotOptions, SnapshotId>(
64+
'post',
65+
'/snapshots',
66+
new Map(),
67+
snapshotsApiFactory,
68+
makeRateLimited(
69+
makeIdempotent(async (serverAPI, connection, event, _params, body) => {
70+
if (body.snapshotType === APISnapshotType.SYSTEM) {
71+
logger.info(`API POST: Store System Snapshot`)
72+
return await serverAPI.storeSystemSnapshot(connection, event, body)
73+
} else if (body.snapshotType === APISnapshotType.PLAYLIST) {
74+
logger.info(`API POST: Store Playlist Snapshot`)
75+
return await serverAPI.storePlaylistSnapshot(connection, event, body)
76+
}
77+
throw new Meteor.Error(400, `Invalid snapshot type`)
78+
}),
79+
SNAPSHOT_RESOURCE
80+
)
81+
)
82+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import {
4040
APISourceLayer,
4141
APIStudio,
4242
APIStudioSettings,
43+
APIPlaylistSnapshotOptions,
44+
APISystemSnapshotOptions,
4345
} from '../../../lib/rest/v1'
4446
import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase'
4547
import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant'
@@ -54,6 +56,7 @@ import {
5456
} from '@sofie-automation/shared-lib/dist/core/constants'
5557
import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets'
5658
import { ForceQuickLoopAutoNext } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings'
59+
import { PlaylistSnapshotOptions, SystemSnapshotOptions } from '@sofie-automation/meteor-lib/dist/api/shapshot'
5760

5861
/*
5962
This file contains functions that convert between the internal Sofie-Core types and types exposed to the external API.
@@ -701,3 +704,18 @@ export function APIBucketFrom(bucket: Bucket): APIBucketComplete {
701704
studioId: unprotectString(bucket.studioId),
702705
}
703706
}
707+
708+
export function systemSnapshotOptionsFrom(options: APISystemSnapshotOptions): SystemSnapshotOptions {
709+
return {
710+
withDeviceSnapshots: !!options.withDeviceSnapshots,
711+
studioId: typeof options.studioId === 'string' ? protectString(options.studioId) : undefined,
712+
}
713+
}
714+
715+
export function playlistSnapshotOptionsFrom(options: APIPlaylistSnapshotOptions): PlaylistSnapshotOptions {
716+
return {
717+
withDeviceSnapshots: !!options.withDeviceSnapshots,
718+
withArchivedDocuments: !!options.withArchivedDocuments,
719+
withTimeline: !!options.withTimeline,
720+
}
721+
}

meteor/server/api/rest/v1/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ import { Meteor } from 'meteor/meteor'
33
import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client'
44
import { MethodContextAPI } from '../../methodContext'
55

6+
export type APIHandler<T, Params, Body, Response> = (
7+
serverAPI: T,
8+
connection: Meteor.Connection,
9+
event: string,
10+
params: Params,
11+
body: Body
12+
) => Promise<ClientAPI.ClientResponse<Response>>
13+
614
export type APIRegisterHook<T> = <Params, Body, Response>(
715
method: 'get' | 'post' | 'put' | 'delete',
816
route: string,

0 commit comments

Comments
 (0)