redactor.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import { Option } from "effect"
  2. import { decodeJson } from "./matching.js"
  3. import { REDACTED, redactHeaders, redactUrl } from "./redaction.js"
  4. import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js"
  5. export type { RedactOptions } from "./types.js"
  6. export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
  7. export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
  8. const identity = <T>(value: T) => value
  9. export interface Redactor {
  10. readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
  11. readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
  12. }
  13. export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
  14. const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
  15. const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
  16. return {
  17. request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
  18. response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
  19. }
  20. }
  21. export interface HeaderOptions {
  22. readonly allow?: ReadonlyArray<string>
  23. readonly redact?: ReadonlyArray<string>
  24. }
  25. export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
  26. request: (snapshot) => ({
  27. ...snapshot,
  28. headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
  29. }),
  30. })
  31. export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
  32. response: (snapshot) => ({
  33. ...snapshot,
  34. headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
  35. }),
  36. })
  37. export interface UrlOptions {
  38. readonly query?: ReadonlyArray<string>
  39. readonly transform?: (url: string) => string
  40. }
  41. export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
  42. request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
  43. })
  44. export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
  45. request: (snapshot) => ({
  46. ...snapshot,
  47. body: Option.match(decodeJson(snapshot.body), {
  48. onNone: () => snapshot.body,
  49. onSome: (parsed) => JSON.stringify(transform(parsed)),
  50. }),
  51. }),
  52. })
  53. export interface DefaultRedactorOverrides {
  54. readonly requestHeaders?: HeaderOptions
  55. readonly responseHeaders?: HeaderOptions
  56. readonly url?: UrlOptions
  57. readonly body?: (parsed: unknown) => unknown
  58. }
  59. const DEFAULT_REDACT_JSON_FIELDS = [
  60. "access_token",
  61. "api_key",
  62. "apikey",
  63. "client_secret",
  64. "password",
  65. "refresh_token",
  66. "secret",
  67. "token",
  68. ]
  69. const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase()
  70. const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): unknown => {
  71. if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields))
  72. if (!value || typeof value !== "object") return value
  73. return Object.fromEntries(
  74. Object.entries(value).map(([key, child]) => [
  75. key,
  76. fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields),
  77. ]),
  78. )
  79. }
  80. const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => {
  81. const redacted = Option.match(decodeJson(value), {
  82. onNone: () => value,
  83. onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)),
  84. })
  85. return transform?.(redacted) ?? redacted
  86. }
  87. export const make = (options: RedactOptions = {}): Redactor => {
  88. const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField))
  89. return compose(
  90. requestHeaders({
  91. allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])],
  92. redact: options.headers,
  93. }),
  94. responseHeaders({
  95. allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])],
  96. redact: options.headers,
  97. }),
  98. url({ query: options.queryParameters, transform: options.url }),
  99. {
  100. request: (snapshot) => ({
  101. ...snapshot,
  102. body: redactBody(snapshot.body, fields, options.body),
  103. }),
  104. response: (snapshot) => ({
  105. ...snapshot,
  106. body: redactBody(snapshot.body, fields, options.body),
  107. }),
  108. },
  109. )
  110. }
  111. export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
  112. compose(
  113. requestHeaders(overrides.requestHeaders),
  114. responseHeaders(overrides.responseHeaders),
  115. url(overrides.url),
  116. ...(overrides.body ? [body(overrides.body)] : []),
  117. )