• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

decentraland / decentraland-gatsby / 27959221825

22 Jun 2026 02:14PM UTC coverage: 55.902% (-1.0%) from 56.918%
27959221825

Pull #1324

github

web-flow
Merge 4b7aff265 into 3810212cf
Pull Request #1324: fix: rename Catalyst.test.ts to follow integration test convention

500 of 1105 branches covered (45.25%)

Branch coverage included in aggregate %.

1257 of 2038 relevant lines covered (61.68%)

11.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.41
/src/utils/api/API.ts
1
import {
2
  AUTH_CHAIN_HEADER_PREFIX,
3
  AUTH_METADATA_HEADER,
4
  AUTH_TIMESTAMP_HEADER,
5
} from 'decentraland-crypto-middleware/lib/types'
6✔
6
import { sleep } from 'radash'
6✔
7

8
import Options, { RequestOptions } from './Options'
6✔
9
import logger from '../../entities/Development/logger'
6✔
10
import { signPayload } from '../auth/identify'
6✔
11
import { getCurrentIdentity } from '../auth/storage'
6✔
12
import FetchError from '../errors/FetchError'
6✔
13
import RequestError from '../errors/RequestError'
6✔
14
import { toBase64 } from '../string/base64'
6✔
15

16
import type { Identity } from '../auth/types'
17

18
export type SearchParamValue = boolean | number | string | Date
19
export type SearchParamData = Record<
20
  string,
21
  undefined | null | SearchParamValue | SearchParamValue[]
22
>
23
export type SearchParamOptions<D extends SearchParamData = SearchParamData> =
24
  Partial<{
25
    dataToTimestamp: boolean
26
    default: Partial<D>
27
  }>
28

29
export default class API {
94✔
30
  static catch<T>(prom: Promise<T>) {
31
    return prom.catch((err) => {
4✔
32
      logger.error(err)
2✔
33
      return null
2✔
34
    })
35
  }
36

37
  static #searchParamsValue(
38
    value: SearchParamValue,
39
    options: SearchParamOptions
40
  ): string {
41
    if (value instanceof Date) {
22✔
42
      return options.dataToTimestamp ? String(value.getTime()) : value.toJSON()
4✔
43
    }
44

45
    return String(value)
18✔
46
  }
47

48
  static fromPagination<T extends { page: number }>(
49
    { page, ...data }: T,
50
    options: { pageSize: number }
51
  ): Omit<T, 'page'> & { limit: number; offset: number } {
52
    return {
6✔
53
      ...data,
54
      limit: options.pageSize,
55
      offset: (page - 1) * options.pageSize,
56
    }
57
  }
58

59
  static searchParams<D extends SearchParamData>(
60
    data: D,
61
    options: SearchParamOptions<D> = {}
3✔
62
  ): URLSearchParams {
63
    const params = new URLSearchParams()
10✔
64
    const keys = Object.keys(data)
10✔
65

66
    for (const key of keys) {
10✔
67
      const value = data[key] as
26✔
68
        | undefined
69
        | null
70
        | SearchParamValue
71
        | SearchParamValue[]
72
      if (value === undefined || value === null) {
26✔
73
        continue
8✔
74
      }
75

76
      if (Array.isArray(value)) {
18✔
77
        for (const each of value) {
2✔
78
          params.append(key, this.#searchParamsValue(each, options))
6✔
79
        }
80
      } else {
81
        params.append(key, this.#searchParamsValue(value, options))
16✔
82
      }
83
    }
84

85
    if (options?.default) {
10✔
86
      for (const param of Object.keys(options.default)) {
2✔
87
        if (data[param] === options.default[param]) {
6✔
88
          params.delete(param)
4✔
89
        }
90
      }
91
    }
92

93
    return params
10✔
94
  }
95

96
  static url(
97
    base: string,
98
    path = '',
4✔
99
    query: Record<string, string> | URLSearchParams = {}
10✔
100
  ) {
101
    if (base.endsWith('/')) {
72✔
102
      base = base.slice(0, -1)
2✔
103
    }
104

105
    if (path !== '' && !path.startsWith('/')) {
72✔
106
      path = '/' + path
8✔
107
    }
108

109
    let params = new URLSearchParams(query).toString()
72✔
110
    if (params) {
72✔
111
      if (path.includes('?')) {
16✔
112
        params = '&' + params
4✔
113
      } else {
114
        params = '?' + params
12✔
115
      }
116
    }
117

118
    return base + path + params
72✔
119
  }
120

121
  readonly baseUrl: string = ''
30✔
122
  readonly defaultOptions: Options = new Options({})
30✔
123
  #fetcher: typeof fetch | null = null
30✔
124
  #fetch: typeof fetch = (
30✔
125
    input: RequestInfo | URL,
126
    init?: RequestInit | undefined
127
  ) => {
128
    if (this.#fetcher) {
×
129
      return this.#fetcher(input, init)
×
130
    }
131

132
    if (typeof fetch !== 'undefined') {
×
133
      return fetch(input, init)
×
134
    }
135

136
    throw new ReferenceError(
×
137
      `fecher is not defined on API, use .setFetcher() to set it`
138
    )
139
  }
140

141
  constructor(baseUrl = '', defaultOptions: Options = new Options({})) {
15!
142
    this.baseUrl = baseUrl || ''
30!
143
    this.defaultOptions = defaultOptions
30✔
144
  }
145

146
  setFetcher(fetcher: typeof fetch) {
147
    this.#fetch = fetcher
28✔
148
    return this
28✔
149
  }
150

151
  url(path: string, query: Record<string, string> | URLSearchParams = {}) {
14✔
152
    return API.url(this.baseUrl, path, query)
28✔
153
  }
154

155
  options(options: RequestOptions = {}) {
12✔
156
    return new Options(options)
24✔
157
  }
158

159
  /** @deprecated use API.searchParams instead */
160
  query<T extends {} = {}>(qs?: T) {
161
    if (!qs) {
×
162
      return ''
×
163
    }
164

165
    const params = new URLSearchParams()
×
166
    for (const key of Object.keys(qs) as (keyof T)[]) {
×
167
      if (qs[key] === null) {
×
168
        params.set(String(key), '')
×
169
      } else if (qs[key] !== undefined) {
×
170
        params.set(String(key), String(qs[key]))
×
171
      }
172
    }
173

174
    const queryString = params.toString()
×
175
    if (!queryString) {
×
176
      return ''
×
177
    }
178

179
    return '?' + queryString
×
180
  }
181

182
  async authorizeOptions(
183
    path: string,
184
    options: Options = new Options({})
×
185
  ): Promise<Options> {
186
    const config = options.getAuthorization()
28✔
187

188
    if (config.identity) {
28!
189
      const identity: Identity | null = getCurrentIdentity()
×
190
      if (!identity?.authChain && !config.optional) {
×
191
        throw new FetchError(
×
192
          path,
193
          options.toObject(),
194
          'Missing identity to autorize the request'
195
        )
196
      }
197

198
      if (identity?.authChain) {
×
199
        options.header(
×
200
          'Authorization',
201
          'Bearer ' + toBase64(JSON.stringify(identity.authChain))
202
        )
203
      }
204
    }
205

206
    return options
28✔
207
  }
208

209
  async signOptions(
210
    path: string,
211
    options: Options = new Options({})
×
212
  ): Promise<Options> {
213
    const config = options.getAuthorization()
28✔
214

215
    if (config.sign) {
28!
216
      const identity = getCurrentIdentity()
×
217
      if (!identity?.authChain && !config.optional) {
×
218
        throw new FetchError(
×
219
          path,
220
          options.toObject(),
221
          'Missing identity to sign the request'
222
        )
223
      }
224

225
      if (identity?.authChain) {
×
226
        const timestamp = String(Date.now())
×
227
        const pathname = new URL(this.url(path), 'https://localhost').pathname
×
228
        const method = options.getMethod() || 'GET'
×
229
        const metadata = JSON.stringify(options.getMetadata())
×
230
        const payload = [method, pathname, timestamp, metadata]
×
231
          .join(':')
232
          .toLowerCase()
233
        const chain = await signPayload(identity, payload)
×
234

235
        chain.forEach((link, i) =>
×
236
          options.header(AUTH_CHAIN_HEADER_PREFIX + i, JSON.stringify(link))
×
237
        )
238
        options.header(AUTH_TIMESTAMP_HEADER, timestamp)
×
239
        options.header(AUTH_METADATA_HEADER, metadata)
×
240
        return options
×
241
      }
242
    }
243

244
    return options
28✔
245
  }
246

247
  timeoutOption() {}
248

249
  async fetch<T extends {}>(
250
    path: string,
251
    options: Options = new Options({})
2✔
252
  ): Promise<T> {
253
    let res: Response
254
    let body = ''
28✔
255
    let json: T = null as any
28✔
256
    const url = this.url(path)
28✔
257

258
    let opt = this.defaultOptions.merge(options)
28✔
259
    opt = await this.authorizeOptions(path, opt)
28✔
260
    opt = await this.signOptions(path, opt)
28✔
261
    const timeout = opt.getTimeout()
28✔
262

263
    try {
28✔
264
      // timeout 0 automatically returns a Request Timeout
265
      if ((timeout.timeout && timeout.timeout <= 0) || timeout.timeout === 0) {
28✔
266
        // if timeoutFallback exists, return it
267
        if ('timeoutFallback' in timeout) {
4✔
268
          res = new Response(JSON.stringify(timeout.timeoutFallback), {
2✔
269
            status: 200,
270
          })
271
        } else {
272
          res = new Response('Request Timeout', { status: 408 })
2✔
273
        }
274

275
        // if timeout exceeds 0, then perform the fetch with a timeout
276
      } else if (timeout.timeout) {
24✔
277
        let completed = false
6✔
278
        const controller = new AbortController()
6✔
279

280
        // race against fetch and timeout
281
        res = await Promise.race([
6✔
282
          this.#fetch(url, opt.toObject({ signal: controller.signal })).then(
283
            (res) => {
284
              completed = true
6✔
285
              return res
6✔
286
            }
287
          ),
288

289
          sleep(timeout.timeout).then(() => {
290
            // abort fetch in background
291
            if (!completed) {
4✔
292
              controller.abort()
4✔
293
            }
294

295
            // if timeoutFallback exists, return it
296
            if ('timeoutFallback' in timeout) {
4✔
297
              return new Response(JSON.stringify(timeout.timeoutFallback), {
2✔
298
                status: 200,
299
              })
300
            }
301

302
            // if there is no timeoutFallback, return a Request Timeout
303
            return new Response('Request Timeout', { status: 408 })
2✔
304
          }),
305
        ])
306

307
        // If not timeout was set then just perform the fetch
308
      } else {
309
        res = await this.#fetch(url, opt.toObject())
18✔
310
      }
311
    } catch (error) {
312
      throw new FetchError(url, opt.toObject(), error.message)
×
313
    }
314

315
    try {
28✔
316
      body = await res.text()
28✔
317
    } catch (error) {
318
      throw new RequestError(url, opt.toObject(), res, body)
×
319
    }
320

321
    try {
28✔
322
      json = JSON.parse(body || '{}') as T
28!
323
    } catch (error) {
324
      console.log('parse error', timeout, error)
4✔
325
      throw new RequestError(
4✔
326
        url,
327
        opt.toObject(),
328
        res,
329
        error.message + ' at ' + body
330
      )
331
    }
332

333
    if (res.status >= 400) {
24!
334
      throw new RequestError(url, opt.toObject(), res, json)
×
335
    }
336

337
    return json
24✔
338
  }
339
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc