Skip to content

feat: field parsing #39

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

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
11 changes: 1 addition & 10 deletions src/benchttp/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,10 @@ export interface Statistics {
min: number
max: number
mean: number
stdDev: number
median: number
standardDeviation: number
deciles: FixedArray<number, 10> | null
quartiles: FixedArray<number, 4> | null
}

export type RequestEvent =
| 'DNSDone'
| 'ConnectDone'
| 'TLSHandshakeDone'
| 'WroteHeaders'
| 'WroteRequest'
| 'GotFirstResponseByte'
| 'PutIdleConn'

export type Distribution<K extends string> = Record<K, number>
42 changes: 42 additions & 0 deletions src/benchttp/field/core/field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
HTTPCodeKey,
IndexKey,
QuantileKey,
RequestEventKey,
RootKey,
StatisticsKey,
} from './key'

export type FieldRepr =
| RequestCountField
| HTTPCodeDistributionField
| ResponseTimeStatisticsField
| RequestEventStatisticsField

export type RequestCountField =
| RootKey.RequestCount
| RootKey.RequestFailureCount
| RootKey.RequestSuccessCount

export type HTTPCodeDistributionField = Depth1<
RootKey.StatusCodesDistribution,
HTTPCodeKey
>

export type ResponseTimeStatisticsField = StatisticsOf<RootKey.ResponseTimes>

export type RequestEventStatisticsField = StatisticsOf<
Depth1<RootKey.RequestEventTimes, RequestEventKey>
>

type StatisticsOf<T extends string> =
| Depth1<T, StatisticsKey>
| Depth2<T, QuantileKey, IndexKey>

type Depth1<K0 extends string, K1 extends string> = `${K0}.${K1}`

type Depth2<
K0 extends string,
K1 extends string,
K2 extends string
> = `${Depth1<K0, K1>}.${K2}`
71 changes: 71 additions & 0 deletions src/benchttp/field/core/key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { asInteger } from '@/lib/casting'
import { HTTPCode } from '@/typing'

export type Key =
| RootKey
| StatisticsKey
| RequestEventKey
| IndexKey
| HTTPCodeKey

export enum RootKey {
RequestCount = 'RequestCount',
RequestFailureCount = 'RequestFailureCount',
RequestSuccessCount = 'RequestSuccessCount',
RequestFailures = 'RequestFailures',
RequestEventTimes = 'RequestEventTimes',
ResponseTimes = 'ResponseTimes',
StatusCodesDistribution = 'StatusCodesDistribution',
}

export enum StatisticsKey {
Min = 'Min',
Max = 'Max',
Mean = 'Mean',
Median = 'Median',
StandardDeviation = 'StandardDeviation',
Deciles = 'Deciles',
Quartiles = 'Quartiles',
}

export type QuantileKey = StatisticsKey.Quartiles | StatisticsKey.Deciles

export enum RequestEventKey {
DNSDone = 'DNSDone',
ConnectDone = 'ConnectDone',
TLSHandshakeDone = 'TLSHandshakeDone',
WroteHeaders = 'WroteHeaders',
WroteRequest = 'WroteRequest',
GotFirstResponseByte = 'GotFirstResponseByte',
BodyRead = 'BodyRead',
PutIdleConn = 'PutIdleConn',
}

export type IndexKey = `${number}`

export type HTTPCodeKey = HTTPCode

// Key validators

export const isOneOfKeys = <K extends Key[]>(
key: string,
keys: K
): key is K[number] => keys.includes(key as Key)

export const isRootKey = (key: string): key is RootKey =>
isOneOfKeys(key, Object.values(RootKey))

export const isStatisticsKey = (key: string): key is StatisticsKey =>
isOneOfKeys(key, Object.values(StatisticsKey))

export const isQuantileKey = (key: string): key is QuantileKey =>
isOneOfKeys(key, [StatisticsKey.Quartiles, StatisticsKey.Deciles])

export const isRequestEventKey = (key: string): key is RequestEventKey =>
isOneOfKeys(key, Object.values(RequestEventKey))

export const isIndexKey = (key: string): key is IndexKey =>
asInteger(key, (n) => n >= 0)

export const isHTTPCodeKey = (key: string): key is HTTPCodeKey =>
asInteger(key, (n) => 100 <= n && n <= 599)
86 changes: 86 additions & 0 deletions src/benchttp/field/core/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { memoize } from '@/lib/memoize'
import { Nullable } from '@/typing/nullable'

import {
isHTTPCodeKey,
isIndexKey,
isRequestEventKey,
isRootKey,
isStatisticsKey,
isOneOfKeys,
RootKey,
StatisticsKey,
Key,
} from './key'

export class FieldNode {
host: Nullable<FieldNode>
key: string

constructor({ host, key }: Pick<FieldNode, 'host' | 'key'>) {
this.host = host
this.key = key
}

get root(): FieldNode {
return memoize(this.#findRoot)
}

get depth(): number {
return memoize(this.#computeDepth)
}

#findRoot = () => this.#reduceHosts((_, host) => host as typeof this, this)

#computeDepth = () => this.#reduceHosts((depth) => depth + 1, 0)

#reduceHosts = <T>(fn: (acc: T, host: FieldNode) => T, init: T): T => {
let accumulator: T = init
this.#forEachHost((host) => {
accumulator = fn(accumulator, host)
})
return accumulator
}

#forEachHost = (callback: (host: FieldNode) => void) => {
// eslint-disable-next-line @typescript-eslint/no-this-alias
let current: FieldNode = this
while (current.host) {
current = current.host
callback(current)
}
return current
}

public isValid = () =>
this.isRoot() ||
this.isStatistics() ||
this.isRequestEvent() ||
this.isIndex() ||
this.isHTTPCode()

private isRoot = () => isRootKey(this.key) && !this.host

private isStatistics = () =>
isStatisticsKey(this.key) &&
(this.host?.is(RootKey.ResponseTimes) || this.host?.isRequestEvent())

private isRequestEvent = () =>
isRequestEventKey(this.key) && this.host?.is(RootKey.RequestEventTimes)

private isIndex = () =>
isIndexKey(this.key) &&
this.host?.isOneOf([
RootKey.RequestFailures,
RootKey.RequestEventTimes,
StatisticsKey.Quartiles,
StatisticsKey.Deciles,
])

private isHTTPCode = () =>
isHTTPCodeKey(this.key) && this.host?.is(RootKey.StatusCodesDistribution)

private is = (key: Key) => this.isOneOf([key])

private isOneOf = (keys: Key[]) => isOneOfKeys(this.key, keys)
}
102 changes: 102 additions & 0 deletions src/benchttp/field/core/parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, test } from 'vitest'

import { FieldRepr } from './field'
import { parseField } from './parse'

describe('parseField', () => {
test('empty field', () => {
expectInvalidField('')
})

test('invalid root field', () => {
expectInvalidField('abc')
})

test('ResponseTimes', () => {
expectValidFields([
'ResponseTimes.Min',
'ResponseTimes.Max',
'ResponseTimes.Mean',
'ResponseTimes.Median',
'ResponseTimes.Deciles',
'ResponseTimes.Deciles.1',
'ResponseTimes.Quartiles',
'ResponseTimes.Quartiles.2',
'ResponseTimes.StandardDeviation',
])
expectInvalidFields([
'ResponseTimes.xxx',
'ResponseTimes.Max.xxx',
'ResponseTimes.1',
'ResponseTimes.Max.1',
'ResponseTimes.Deciles.-1',
])
})

test('RequestEventTimes', () => {
expectValidFields([
'RequestEventTimes.DNSDone.Min',
'RequestEventTimes.ConnectDone.Max',
'RequestEventTimes.TLSHandshakeDone.Mean',
'RequestEventTimes.WroteHeaders.Median',
'RequestEventTimes.WroteRequest.Deciles',
'RequestEventTimes.GotFirstResponseByte.Quartiles',
'RequestEventTimes.BodyRead.StandardDeviation',
'RequestEventTimes.PutIdleConn.StandardDeviation',
])
expectInvalidFields([
'RequestEventTimes.xxx',
'RequestEventTimes.ConnectDone.xxx',
'RequestEventTimes.ConnectDone.Mean.xxx',
])
})

test('StatusCodesDistribution', () => {
expectValidFields([
'StatusCodesDistribution.101',
'StatusCodesDistribution.200',
'StatusCodesDistribution.302',
'StatusCodesDistribution.418',
'StatusCodesDistribution.500',
])
expectInvalidFields([
'StatusCodesDistribution.xxx',
'StatusCodesDistribution.-1',
'StatusCodesDistribution.99',
'StatusCodesDistribution.600',
'StatusCodesDistribution.200.xxx',
])
})
})

const expectValidField = (field: FieldRepr) => {
const parsedField = parseField(field)

expect(parsedField?.isValid()).toBeTruthy()
expect(parsedField?.root.key).toEqual(getRoot(field))
expect(parsedField?.depth).toEqual(getDepth(field))
if (hasParent(field)) {
expectValidField(getParent(field))
}
}

const expectValidFields = (fields: FieldRepr[]) => {
fields.forEach(expectValidField)
}

const expectInvalidField = (field: string) =>
// @ts-expect-error - testing context
expect(parseField(field)).toBeNull()

const expectInvalidFields = (fields: string[]) => {
fields.forEach(expectInvalidField)
}

const hasParent = (field: FieldRepr) => field.split('.').length > 1

const getParent = (field: FieldRepr): FieldRepr =>
field.split('.').slice(0, -1).join('.') as FieldRepr

const getDepth = (field: FieldRepr): number => field.split('.').length - 1

const getRoot = (field: FieldRepr) => field.split('.')[0]
28 changes: 28 additions & 0 deletions src/benchttp/field/core/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Nullable } from '@/typing/nullable'

import { FieldRepr } from './field'
import { FieldNode } from './node'

export const parseField = (field: FieldRepr): Nullable<FieldNode> => {
const stack = field.split('.')
return parseFieldRecursive(null, stack)
}

function parseFieldRecursive(
currentField: Nullable<FieldNode>,
stack: string[]
): Nullable<FieldNode> {
const [nextKey, ...nextStack] = stack
const isLast = !nextKey

if (isLast) {
return currentField
}

const nextParsed = new FieldNode({ host: currentField, key: nextKey })
if (!nextParsed.isValid()) {
return null
}

return parseFieldRecursive(nextParsed, nextStack)
}
Loading