Skip to content

Commit

Permalink
feat: make project-id redundant for check-ins configuration api
Browse files Browse the repository at this point in the history
  • Loading branch information
subzero10 committed Dec 27, 2023
1 parent 2bef753 commit 7eb06d0
Show file tree
Hide file tree
Showing 10 changed files with 319 additions and 236 deletions.
23 changes: 12 additions & 11 deletions packages/js/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ class Honeybadger extends Client {
throw new Error('Honeybadger.showUserFeedbackForm() is not supported on the server-side')
}

async checkIn(idOrName: string): Promise<void> {
async checkIn(idOrSlug: string): Promise<void> {
try {
const id = await this.getCheckInId(idOrName)
const id = await this.getCheckInId(idOrSlug)
await this.__transport
.send({
method: 'GET',
Expand All @@ -104,30 +104,31 @@ class Honeybadger extends Client {
this.logger.info('CheckIn sent')
}
catch (err) {
this.logger.error(`CheckIn[${idOrName}] failed: an unknown error occurred.`, `message=${err.message}`)
this.logger.error(`CheckIn[${idOrSlug}] failed: an unknown error occurred.`, `message=${err.message}`)
}
}

private async getCheckInId(idOrName: string): Promise<string> {
private async getCheckInId(idOrSlug: string): Promise<string> {
if (!this.config.checkins || this.config.checkins.length === 0) {
return idOrName
return idOrSlug
}

const localCheckIn = this.config.checkins.find(c => c.name === idOrName)
const localCheckIn = this.config.checkins.find(c => c.slug === idOrSlug)
if (!localCheckIn) {
return idOrName
return idOrSlug
}

if (localCheckIn.id) {
return localCheckIn.id
}

const projectCheckIns = await this.__checkInsClient.listForProject(localCheckIn.projectId)
const remoteCheckIn = projectCheckIns.find(c => c.name === localCheckIn.name)
const projectId = await this.__checkInsClient.getProjectId(this.config.apiKey)
const projectCheckIns = await this.__checkInsClient.listForProject(projectId)
const remoteCheckIn = projectCheckIns.find(c => c.slug === localCheckIn.slug)
if (!remoteCheckIn) {
this.logger.debug(`Checkin[${idOrName}] was not found on HB. This should not happen. Was the sync command executed?`)
this.logger.debug(`Checkin[${idOrSlug}] was not found on HB. This should not happen. Was the sync command executed?`)

return idOrName
return idOrSlug
}

// store the id in-memory, so subsequent check-ins won't have to call the API
Expand Down
53 changes: 32 additions & 21 deletions packages/js/src/server/check-ins-manager/check-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import { CheckInDto, CheckInPayload, CheckInResponsePayload } from './types'

export class CheckIn implements CheckInDto {
id?: string
projectId: string
name: string
name?: string
scheduleType: 'simple' | 'cron'
slug?: string
slug: string
reportPeriod?: string
gracePeriod?: string
cronSchedule?: string
Expand All @@ -27,7 +26,6 @@ export class CheckIn implements CheckInDto {
this.gracePeriod = props.gracePeriod
this.cronSchedule = props.cronSchedule
this.cronTimezone = props.cronTimezone
this.projectId = props.projectId
this.deleted = false
}

Expand All @@ -40,36 +38,43 @@ export class CheckIn implements CheckInDto {
}

public validate() {
if (!this.projectId) {
throw new Error('projectId is required for each check-in')
}

if (!this.name) {
throw new Error('name is required for each check-in')
if (!this.slug) {
throw new Error('slug is required for each check-in')
}

if (!this.scheduleType) {
throw new Error('scheduleType is required for each check-in')
}

if (!['simple', 'cron'].includes(this.scheduleType)) {
throw new Error(`${this.name} [scheduleType] must be "simple" or "cron"`)
throw new Error(`${this.slug} [scheduleType] must be "simple" or "cron"`)
}

if (this.scheduleType === 'simple' && !this.reportPeriod) {
throw new Error(`${this.name} [reportPeriod] is required for simple check-ins`)
throw new Error(`${this.slug} [reportPeriod] is required for simple check-ins`)
}

if (this.scheduleType === 'cron' && !this.cronSchedule) {
throw new Error(`${this.name} [cronSchedule] is required for cron check-ins`)
throw new Error(`${this.slug} [cronSchedule] is required for cron check-ins`)
}
}

public update(other: CheckIn) {
this.id = other.id
this.slug = other.slug
this.name = other.name
this.scheduleType = other.scheduleType
this.reportPeriod = other.reportPeriod
this.gracePeriod = other.gracePeriod
this.cronSchedule = other.cronSchedule
this.cronTimezone = other.cronTimezone
}

public asRequestPayload() {
const payload: CheckInPayload = {
name: this.name,
schedule_type: this.scheduleType,
slug: this.slug ?? '', // default is empty string
slug: this.slug,
grace_period: this.gracePeriod ?? '' // default is empty string
}

Expand All @@ -88,21 +93,27 @@ export class CheckIn implements CheckInDto {
* Compares two check-ins, usually the one from the API and the one from the config file.
* If the one in the config file does not match the check-in from the API,
* then we issue an update request.
*
* `name`, `gracePeriod` and `cronTimezone` are optional fields that are automatically
* set to a value from the server if one is not provided,
* so we ignore their values if they are not set locally.
*/
public isInSync(other: CheckIn) {
return this.name === other.name
&& this.projectId === other.projectId
const ignoreNameCheck = this.name === undefined
const ignoreGracePeriodCheck = this.gracePeriod === undefined
const ignoreCronTimezoneCheck = this.cronTimezone === undefined

return this.slug === other.slug
&& this.scheduleType === other.scheduleType
&& this.reportPeriod === other.reportPeriod
&& this.cronSchedule === other.cronSchedule
&& (this.slug ?? '') === (other.slug ?? '')
&& (this.gracePeriod ?? '') === (other.gracePeriod ?? '')
&& (this.cronTimezone ?? '') === (other.cronTimezone ?? '')
&& (ignoreNameCheck || this.name === other.name)
&& (ignoreGracePeriodCheck || this.gracePeriod === other.gracePeriod)
&& (ignoreCronTimezoneCheck || this.cronTimezone === other.cronTimezone)
}

public static fromResponsePayload(projectId: string, payload: CheckInResponsePayload) {
public static fromResponsePayload(payload: CheckInResponsePayload) {
return new CheckIn({
projectId,
id: payload.id,
name: payload.name,
slug: payload.slug,
Expand Down
84 changes: 58 additions & 26 deletions packages/js/src/server/check-ins-manager/client.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,53 @@
import { Types } from '@honeybadger-io/core'
import { CheckIn } from './check-in'
import { CheckInResponsePayload } from './types';
import { CheckInResponsePayload, CheckInsConfig } from './types';

export class CheckInsClient {
private readonly BASE_URL = 'https://app.honeybadger.io'
private readonly cache: Record<string, CheckIn[]>
private readonly config: { personalAuthToken: string; logger: Types.Logger }
private readonly config: Pick<CheckInsConfig, 'apiKey' | 'personalAuthToken' | 'logger'>
private readonly logger: Types.Logger
private readonly transport: Types.Transport

constructor(config: { personalAuthToken: string; logger: Types.Logger }, transport: Types.Transport) {
private projectId: string | null
private cache: CheckIn[] | null

constructor(config: Pick<CheckInsConfig, 'apiKey' | 'personalAuthToken' | 'logger'>, transport: Types.Transport) {
this.transport = transport
this.config = config
this.logger = config.logger
this.cache = {}
this.cache = null
this.projectId = null
}

public async getProjectId(projectApiKey: string): Promise<string> {
if (this.projectId) {
return this.projectId
}

if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}

const response = await this.transport.send({
method: 'GET',
headers: this.getHeaders(),
endpoint: `${this.BASE_URL}/v2/project_keys/${projectApiKey}`,
logger: this.logger,
})

if (response.statusCode !== 200) {
throw new Error(`Failed to fetch project[${projectApiKey}]: ${this.getErrorMessage(response.body)}`)
}

const data: { project: { id: string; name: string; created_at: string; } } = JSON.parse(response.body)
this.projectId = data?.project?.id

return this.projectId
}

public async listForProject(projectId: string): Promise<CheckIn[]> {
if (this.cache[projectId]) {
return this.cache[projectId]
if (this.cache !== null) {
return this.cache
}

if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
Expand All @@ -37,10 +66,9 @@ export class CheckInsClient {
}

const data: { results: CheckInResponsePayload[] } = JSON.parse(response.body)
const checkIns = data.results.map((checkin) => CheckIn.fromResponsePayload(projectId, checkin))
this.cache[projectId] = checkIns
this.cache = data.results.map((checkin) => CheckIn.fromResponsePayload(checkin))

return checkIns
return this.cache
}

public async get(projectId: string, checkInId: string): Promise<CheckIn> {
Expand All @@ -60,68 +88,72 @@ export class CheckInsClient {
}

const data: CheckInResponsePayload = JSON.parse(response.body)
const checkIn = CheckIn.fromResponsePayload(projectId, data)
checkIn.projectId = projectId

return checkIn
return CheckIn.fromResponsePayload(data)
}

public async create(checkIn: CheckIn): Promise<CheckIn> {
public async create(projectId: string, checkIn: CheckIn): Promise<CheckIn> {
if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}

const response = await this.transport.send({
method: 'POST',
headers: this.getHeaders(),
endpoint: `${this.BASE_URL}/v2/projects/${checkIn.projectId}/check_ins`,
endpoint: `${this.BASE_URL}/v2/projects/${projectId}/check_ins`,
logger: this.logger,
}, { check_in: checkIn.asRequestPayload() })

if (response.statusCode !== 201) {
throw new Error(`Failed to create check-in[${checkIn.name}] for project[${checkIn.projectId}]: ${this.getErrorMessage(response.body)}`)
throw new Error(`Failed to create check-in[${checkIn.slug}] for project[${projectId}]: ${this.getErrorMessage(response.body)}`)
}

const data: CheckInResponsePayload = JSON.parse(response.body)
const result = CheckIn.fromResponsePayload(checkIn.projectId, data)
result.projectId = checkIn.projectId
const checkin = CheckIn.fromResponsePayload(data)
this.cache?.push(checkin)

return result
return checkin
}

public async update(checkIn: CheckIn): Promise<CheckIn> {
public async update(projectId: string, checkIn: CheckIn): Promise<CheckIn> {
if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}

const response = await this.transport.send({
method: 'PUT',
headers: this.getHeaders(),
endpoint: `${this.BASE_URL}/v2/projects/${checkIn.projectId}/check_ins/${checkIn.id}`,
endpoint: `${this.BASE_URL}/v2/projects/${projectId}/check_ins/${checkIn.id}`,
logger: this.logger,
}, { check_in: checkIn.asRequestPayload() })

if (response.statusCode !== 204) {
throw new Error(`Failed to update checkin[${checkIn.name}] for project[${checkIn.projectId}]: ${this.getErrorMessage(response.body)}`)
throw new Error(`Failed to update checkin[${checkIn.slug}] for project[${projectId}]: ${this.getErrorMessage(response.body)}`)
}

const cached = this.cache?.find(c => c.slug === checkIn.slug)
cached?.update(checkIn)

return checkIn
}

public async remove(checkIn: CheckIn): Promise<void> {
public async remove(projectId: string, checkIn: CheckIn): Promise<void> {
if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}

const response = await this.transport.send({
method: 'DELETE',
headers: this.getHeaders(),
endpoint: `${this.BASE_URL}/v2/projects/${checkIn.projectId}/check_ins/${checkIn.id}`,
endpoint: `${this.BASE_URL}/v2/projects/${projectId}/check_ins/${checkIn.id}`,
logger: this.logger,
})

if (response.statusCode !== 204) {
throw new Error(`Failed to remove checkin[${checkIn.name}] for project[${checkIn.projectId}]: ${this.getErrorMessage(response.body)}`)
throw new Error(`Failed to remove checkin[${checkIn.slug}] for project[${projectId}]: ${this.getErrorMessage(response.body)}`)
}

if (this.cache) {
this.cache = this.cache.filter(c => c.slug !== checkIn.slug)
}
}

Expand Down
Loading

0 comments on commit 7eb06d0

Please sign in to comment.