-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1373 from tv2norge-collab/contribute/EAV-168
feat: add snapshot creation to Stable API
- Loading branch information
Showing
23 changed files
with
645 additions
and
62 deletions.
There are no files selected for viewing
45 changes: 45 additions & 0 deletions
45
meteor/server/api/rest/v1/__tests__/idempotencyService.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
32
meteor/server/api/rest/v1/__tests__/rateLimitingService.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.