apimo.js 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +37 -0
- package/.github/workflows/publish.yml +69 -0
- package/.idea/apimo.js.iml +13 -0
- package/.idea/copilotDiffState.xml +43 -0
- package/.idea/inspectionProfiles/Project_Default.xml +6 -0
- package/.idea/jsLinters/eslint.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/prettier.xml +6 -0
- package/.idea/vcs.xml +6 -0
- package/README.md +91 -0
- package/dist/src/consts/catalogs.d.ts +2 -0
- package/dist/src/consts/catalogs.js +53 -0
- package/dist/src/consts/languages.d.ts +2 -0
- package/dist/src/consts/languages.js +20 -0
- package/dist/src/core/api.d.ts +389 -0
- package/dist/src/core/api.js +157 -0
- package/dist/src/core/api.test.d.ts +1 -0
- package/dist/src/core/api.test.js +246 -0
- package/dist/src/core/converters.d.ts +4 -0
- package/dist/src/core/converters.js +4 -0
- package/dist/src/schemas/agency.d.ts +416 -0
- package/dist/src/schemas/agency.js +61 -0
- package/dist/src/schemas/common.d.ts +153 -0
- package/dist/src/schemas/common.js +47 -0
- package/dist/src/schemas/internal.d.ts +3 -0
- package/dist/src/schemas/internal.js +11 -0
- package/dist/src/schemas/property.d.ts +1500 -0
- package/dist/src/schemas/property.js +238 -0
- package/dist/src/services/storage/dummy.cache.d.ts +10 -0
- package/dist/src/services/storage/dummy.cache.js +28 -0
- package/dist/src/services/storage/dummy.cache.test.d.ts +1 -0
- package/dist/src/services/storage/dummy.cache.test.js +96 -0
- package/dist/src/services/storage/filesystem.cache.d.ts +18 -0
- package/dist/src/services/storage/filesystem.cache.js +85 -0
- package/dist/src/services/storage/filesystem.cache.test.d.ts +1 -0
- package/dist/src/services/storage/filesystem.cache.test.js +197 -0
- package/dist/src/services/storage/memory.cache.d.ts +20 -0
- package/dist/src/services/storage/memory.cache.js +62 -0
- package/dist/src/services/storage/memory.cache.test.d.ts +1 -0
- package/dist/src/services/storage/memory.cache.test.js +80 -0
- package/dist/src/services/storage/types.d.ts +16 -0
- package/dist/src/services/storage/types.js +4 -0
- package/dist/src/types/index.d.ts +4 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/utils/url.d.ts +14 -0
- package/dist/src/utils/url.js +11 -0
- package/dist/src/utils/url.test.d.ts +1 -0
- package/dist/src/utils/url.test.js +18 -0
- package/dist/vitest.config.d.ts +2 -0
- package/dist/vitest.config.js +6 -0
- package/eslint.config.mjs +3 -0
- package/package.json +45 -0
- package/src/consts/catalogs.ts +55 -0
- package/src/consts/languages.ts +22 -0
- package/src/core/api.test.ts +308 -0
- package/src/core/api.ts +230 -0
- package/src/core/converters.ts +7 -0
- package/src/schemas/agency.ts +66 -0
- package/src/schemas/common.ts +67 -0
- package/src/schemas/internal.ts +13 -0
- package/src/schemas/property.ts +257 -0
- package/src/services/storage/dummy.cache.test.ts +110 -0
- package/src/services/storage/dummy.cache.ts +21 -0
- package/src/services/storage/filesystem.cache.test.ts +243 -0
- package/src/services/storage/filesystem.cache.ts +94 -0
- package/src/services/storage/memory.cache.test.ts +94 -0
- package/src/services/storage/memory.cache.ts +69 -0
- package/src/services/storage/types.ts +20 -0
- package/src/types/index.ts +5 -0
- package/src/utils/url.test.ts +21 -0
- package/src/utils/url.ts +27 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { MockedFunction } from 'vitest'
|
|
2
|
+
import type { CatalogName } from '../consts/catalogs'
|
|
3
|
+
import type { ApiCulture } from '../consts/languages'
|
|
4
|
+
import { afterEach, beforeEach, it as defaultIt, describe, expect, vi } from 'vitest'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { DummyCache } from '../services/storage/dummy.cache'
|
|
7
|
+
import { MemoryCache } from '../services/storage/memory.cache'
|
|
8
|
+
import { Api, DEFAULT_BASE_URL } from './api'
|
|
9
|
+
|
|
10
|
+
// Mock fetch globally
|
|
11
|
+
const mockFetch = vi.fn() as MockedFunction<typeof fetch>
|
|
12
|
+
|
|
13
|
+
interface ResponseMockerConfig {
|
|
14
|
+
ok?: boolean
|
|
15
|
+
status?: number
|
|
16
|
+
json?: () => any
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ResponseMocker = (config?: ResponseMockerConfig) => void
|
|
20
|
+
|
|
21
|
+
const PROVIDER = '0'
|
|
22
|
+
const TOKEN = 'TOKEN'
|
|
23
|
+
|
|
24
|
+
const BasicAuthHeaders = {
|
|
25
|
+
Authorization: `Basic ${btoa(`${PROVIDER}:${TOKEN}`)}`,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const it = defaultIt.extend<{
|
|
29
|
+
api: Api
|
|
30
|
+
mockResponse: ResponseMocker
|
|
31
|
+
}>({
|
|
32
|
+
// eslint-disable-next-line no-empty-pattern
|
|
33
|
+
api: async ({}, use) => {
|
|
34
|
+
let api: Api | null = new Api('0', 'TOKEN', {
|
|
35
|
+
catalogs: {
|
|
36
|
+
transform: {
|
|
37
|
+
active: false,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
await use(api)
|
|
42
|
+
api = null
|
|
43
|
+
},
|
|
44
|
+
// eslint-disable-next-line no-empty-pattern
|
|
45
|
+
mockResponse: async ({}, use) => {
|
|
46
|
+
const mockResponse: ResponseMocker = (config) => {
|
|
47
|
+
mockFetch.mockResolvedValue({
|
|
48
|
+
ok: config?.ok ?? true,
|
|
49
|
+
status: config?.status ?? 200,
|
|
50
|
+
json: config?.json ? vi.fn().mockResolvedValue(config.json()) : vi.fn().mockResolvedValue({}),
|
|
51
|
+
text: vi.fn(),
|
|
52
|
+
headers: new Headers(),
|
|
53
|
+
statusText: 'OK',
|
|
54
|
+
url: '',
|
|
55
|
+
redirected: false,
|
|
56
|
+
type: 'basic',
|
|
57
|
+
body: null,
|
|
58
|
+
bodyUsed: false,
|
|
59
|
+
clone: vi.fn(),
|
|
60
|
+
arrayBuffer: vi.fn(),
|
|
61
|
+
blob: vi.fn(),
|
|
62
|
+
formData: vi.fn(),
|
|
63
|
+
} as unknown as Response)
|
|
64
|
+
}
|
|
65
|
+
await use(mockResponse)
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('api', () => {
|
|
70
|
+
let mockResponse: Response
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
// Mock global fetch
|
|
74
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
75
|
+
|
|
76
|
+
// Create a mock response object
|
|
77
|
+
mockResponse = {
|
|
78
|
+
ok: true,
|
|
79
|
+
status: 200,
|
|
80
|
+
json: vi.fn(),
|
|
81
|
+
text: vi.fn(),
|
|
82
|
+
headers: new Headers(),
|
|
83
|
+
statusText: 'OK',
|
|
84
|
+
url: '',
|
|
85
|
+
redirected: false,
|
|
86
|
+
type: 'basic',
|
|
87
|
+
body: null,
|
|
88
|
+
bodyUsed: false,
|
|
89
|
+
clone: vi.fn(),
|
|
90
|
+
arrayBuffer: vi.fn(),
|
|
91
|
+
blob: vi.fn(),
|
|
92
|
+
formData: vi.fn(),
|
|
93
|
+
} as unknown as Response
|
|
94
|
+
|
|
95
|
+
mockFetch.mockResolvedValue(mockResponse)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
vi.unstubAllGlobals()
|
|
100
|
+
vi.clearAllMocks()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('constructor', () => {
|
|
104
|
+
it('should accept a provider, a token and a base config', ({ api }) => {
|
|
105
|
+
expect(api).toBeInstanceOf(Api)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should use default config when no additional config provided', ({ api }) => {
|
|
109
|
+
expect(api.config).toStrictEqual({
|
|
110
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
111
|
+
culture: 'en' as ApiCulture,
|
|
112
|
+
catalogs: {
|
|
113
|
+
cache: {
|
|
114
|
+
active: true,
|
|
115
|
+
adapter: expect.any(MemoryCache),
|
|
116
|
+
},
|
|
117
|
+
transform: {
|
|
118
|
+
active: false,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should merge custom config with defaults', () => {
|
|
125
|
+
const testApi = new Api('provider', 'token', {
|
|
126
|
+
baseUrl: 'https://custom.api.com',
|
|
127
|
+
culture: 'fr' as ApiCulture,
|
|
128
|
+
catalogs: {
|
|
129
|
+
cache: {
|
|
130
|
+
active: false,
|
|
131
|
+
adapter: new DummyCache(),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(testApi.config).toStrictEqual({
|
|
137
|
+
baseUrl: 'https://custom.api.com',
|
|
138
|
+
culture: 'fr',
|
|
139
|
+
catalogs: {
|
|
140
|
+
cache: {
|
|
141
|
+
active: false,
|
|
142
|
+
adapter: expect.any(DummyCache),
|
|
143
|
+
},
|
|
144
|
+
transform: {
|
|
145
|
+
active: true,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should use provided cache adapter', () => {
|
|
152
|
+
const testApi = new Api('provider', 'token', {
|
|
153
|
+
catalogs: {
|
|
154
|
+
cache: {
|
|
155
|
+
adapter: new DummyCache(),
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
expect(testApi.cache).toBeInstanceOf(DummyCache)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should use DummyCache when cache is not active', () => {
|
|
163
|
+
const testApi = new Api('provider', 'token', {
|
|
164
|
+
catalogs: {
|
|
165
|
+
cache: {
|
|
166
|
+
active: false,
|
|
167
|
+
adapter: new MemoryCache(),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
expect(testApi.cache).toBeInstanceOf(DummyCache)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('fetch', () => {
|
|
176
|
+
it('should have the right authorization headers when fetching', async () => {
|
|
177
|
+
const testApi = new Api('provider', 'token')
|
|
178
|
+
await testApi.fetch(DEFAULT_BASE_URL)
|
|
179
|
+
|
|
180
|
+
expect(mockFetch).toHaveBeenCalledExactlyOnceWith(
|
|
181
|
+
DEFAULT_BASE_URL,
|
|
182
|
+
{
|
|
183
|
+
headers: {
|
|
184
|
+
Authorization: `Basic ${btoa('provider:token')}`,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should merge additional headers with authorization', async ({ api }) => {
|
|
191
|
+
const customHeaders = { 'Content-Type': 'application/json' }
|
|
192
|
+
await api.fetch(DEFAULT_BASE_URL, { headers: customHeaders })
|
|
193
|
+
|
|
194
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
195
|
+
DEFAULT_BASE_URL,
|
|
196
|
+
{
|
|
197
|
+
headers: {
|
|
198
|
+
...BasicAuthHeaders,
|
|
199
|
+
'Content-Type': 'application/json',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should pass through other fetch options', async ({ api }) => {
|
|
206
|
+
const options = {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
body: JSON.stringify({ test: 'data' }),
|
|
209
|
+
headers: { 'Custom-Header': 'value' },
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await api.fetch(DEFAULT_BASE_URL, options)
|
|
213
|
+
|
|
214
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
215
|
+
DEFAULT_BASE_URL,
|
|
216
|
+
{
|
|
217
|
+
method: 'POST',
|
|
218
|
+
body: JSON.stringify({ test: 'data' }),
|
|
219
|
+
headers: {
|
|
220
|
+
...BasicAuthHeaders,
|
|
221
|
+
'Custom-Header': 'value',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should handle rate limiting with Bottleneck', async ({ api }) => {
|
|
228
|
+
// Make multiple concurrent requests to test rate limiting
|
|
229
|
+
const promises = Array.from({ length: 3 }, () => api.fetch(DEFAULT_BASE_URL))
|
|
230
|
+
await Promise.all(promises)
|
|
231
|
+
|
|
232
|
+
expect(mockFetch).toHaveBeenCalledTimes(3)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('get', () => {
|
|
237
|
+
it('should fetch and parse according to the specified schema', async ({ mockResponse, api }) => {
|
|
238
|
+
mockResponse({
|
|
239
|
+
json: () => ({ success: true }),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const spy = vi.spyOn(api, 'fetch')
|
|
243
|
+
await api.get(['path', 'to', 'catalogs'], z.object({ success: z.boolean() }), { culture: 'en' })
|
|
244
|
+
expect(spy).toHaveBeenCalledExactlyOnceWith(
|
|
245
|
+
new URL('https://api.apimo.pro/path/to/catalogs?culture=en'),
|
|
246
|
+
)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
describe('populateCache', () => {
|
|
251
|
+
it('should populate cache without returning entry when no id provided', async ({ api, mockResponse }) => {
|
|
252
|
+
const catalogName: CatalogName = 'property_type'
|
|
253
|
+
const culture: ApiCulture = 'en'
|
|
254
|
+
const mockEntries = [
|
|
255
|
+
{ id: 1, name: 'Apartment', name_plurial: 'Apartments' },
|
|
256
|
+
{ id: 2, name: 'House', name_plurial: 'Houses' },
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
mockResponse({
|
|
260
|
+
json: () => mockEntries,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const result = await api.populateCache(catalogName, culture)
|
|
264
|
+
|
|
265
|
+
expect(result).toBeUndefined()
|
|
266
|
+
expect(mockFetch).toHaveBeenCalledExactlyOnceWith(
|
|
267
|
+
new URL(`https://api.apimo.pro/catalogs/${catalogName}?culture=${culture}`),
|
|
268
|
+
{
|
|
269
|
+
headers: BasicAuthHeaders,
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('should populate cache and return specific entry when id provided', async ({ api, mockResponse }) => {
|
|
275
|
+
const catalogName: CatalogName = 'property_type'
|
|
276
|
+
const culture: ApiCulture = 'en'
|
|
277
|
+
const mockEntries = [
|
|
278
|
+
{ id: 1, name: 'Apartment', name_plurial: 'Apartments' },
|
|
279
|
+
{ id: 2, name: 'House', name_plurial: 'Houses' },
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
mockResponse({
|
|
283
|
+
json: () => mockEntries,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const result = await api.populateCache(catalogName, culture, 1)
|
|
287
|
+
|
|
288
|
+
expect(result).toEqual({
|
|
289
|
+
name: 'Apartment',
|
|
290
|
+
namePlural: 'Apartments',
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('should return null when requested id not found', async ({ api, mockResponse }) => {
|
|
295
|
+
const catalogName: CatalogName = 'property_type'
|
|
296
|
+
const culture: ApiCulture = 'en'
|
|
297
|
+
const mockEntries = [
|
|
298
|
+
{ id: 1, name: 'Apartment', name_plurial: 'Apartments' },
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
mockResponse({ json: () => mockEntries })
|
|
302
|
+
|
|
303
|
+
const result = await api.populateCache(catalogName, culture, 999)
|
|
304
|
+
|
|
305
|
+
expect(result).toBeNull()
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
})
|
package/src/core/api.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { CatalogName } from '../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../consts/languages'
|
|
3
|
+
import type { CatalogDefinition, CatalogEntry, CatalogTransformer, LocalizedCatalogTransformer } from '../schemas/common'
|
|
4
|
+
import type { ApiCacheAdapter, CatalogEntryName } from '../services/storage/types'
|
|
5
|
+
import type { DeepPartial } from '../types'
|
|
6
|
+
import type { ApiSearchParams } from '../utils/url'
|
|
7
|
+
import Bottleneck from 'bottleneck'
|
|
8
|
+
import { merge } from 'merge-anything'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { getAgencySchema } from '../schemas/agency'
|
|
11
|
+
import { CatalogDefinitionSchema, CatalogEntrySchema } from '../schemas/common'
|
|
12
|
+
import { getPropertySchema } from '../schemas/property'
|
|
13
|
+
import { DummyCache } from '../services/storage/dummy.cache'
|
|
14
|
+
import { MemoryCache } from '../services/storage/memory.cache'
|
|
15
|
+
import { CacheExpiredError } from '../services/storage/types'
|
|
16
|
+
import { makeApiUrl } from '../utils/url'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ApiConfig
|
|
20
|
+
* ---
|
|
21
|
+
*
|
|
22
|
+
* The general config, used to create an API wrapper. It exports major endpoints as methods.
|
|
23
|
+
* Internally, it's a simple wrapper to node:fetch with a neater syntax.
|
|
24
|
+
*/
|
|
25
|
+
export interface AdditionalConfig {
|
|
26
|
+
// Base path for API access. Defaults to "https://api.apimo.pro/".
|
|
27
|
+
baseUrl: string
|
|
28
|
+
// The default language to use when none is provided. Translates to "culture" in the API.
|
|
29
|
+
culture: ApiCulture
|
|
30
|
+
// Catalog related configuration
|
|
31
|
+
catalogs: {
|
|
32
|
+
// Caching of catalogs, for faster transformation
|
|
33
|
+
cache: {
|
|
34
|
+
// Whether to use the catalog caching. A value of false means that catalogs won't be cached. You will need to supply your own `catalogs.transform.transformFn`.
|
|
35
|
+
active: boolean
|
|
36
|
+
// Where to store the catalogs cache. Currently only file is supported.
|
|
37
|
+
adapter: ApiCacheAdapter
|
|
38
|
+
}
|
|
39
|
+
// Catalog transformation related configuration
|
|
40
|
+
transform: {
|
|
41
|
+
// Whether to use the catalog transformation. A value of false will apply an identity function to the catalog ids.
|
|
42
|
+
active: boolean
|
|
43
|
+
// If provided, the function that will replace the default catalog transformer function.
|
|
44
|
+
transformFn?: CatalogTransformer
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_BASE_URL = 'https://api.apimo.pro'
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_ADDITIONAL_CONFIG: AdditionalConfig = {
|
|
52
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
53
|
+
culture: 'en',
|
|
54
|
+
catalogs: {
|
|
55
|
+
cache: {
|
|
56
|
+
active: true,
|
|
57
|
+
adapter: new MemoryCache(),
|
|
58
|
+
},
|
|
59
|
+
transform: {
|
|
60
|
+
active: true,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class Api {
|
|
66
|
+
readonly config: AdditionalConfig
|
|
67
|
+
readonly cache: ApiCacheAdapter
|
|
68
|
+
readonly limiter: Bottleneck
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
// The site identifier, in a string of numbers format. You can request yours by contacting Apimo.net customer service.
|
|
72
|
+
private readonly provider: string,
|
|
73
|
+
// The secret token for API authentication
|
|
74
|
+
private readonly token: string,
|
|
75
|
+
// Additional config, to tweak how the API is handled
|
|
76
|
+
config: DeepPartial<AdditionalConfig> = DEFAULT_ADDITIONAL_CONFIG,
|
|
77
|
+
) {
|
|
78
|
+
this.config = merge(DEFAULT_ADDITIONAL_CONFIG, config) as AdditionalConfig
|
|
79
|
+
this.cache = this.config.catalogs.cache.active ? this.config.catalogs.cache.adapter : new DummyCache()
|
|
80
|
+
this.limiter = new Bottleneck({
|
|
81
|
+
reservoir: 10,
|
|
82
|
+
reservoirRefreshAmount: 10,
|
|
83
|
+
reservoirRefreshInterval: 1000,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An override of fetch that adds the required Authorization header to every request.
|
|
89
|
+
*/
|
|
90
|
+
public fetch(...parameters: Parameters<typeof fetch>): Promise<Response> {
|
|
91
|
+
const [input, init] = parameters
|
|
92
|
+
const extendedInit: RequestInit = {
|
|
93
|
+
...init,
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Basic ${btoa(`${this.provider}:${this.token}`)}`,
|
|
96
|
+
...init?.headers,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return this.limiter.schedule(() => fetch(input, extendedInit))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public async get<S extends z.Schema>(path: string[], schema: S, options?: Partial<ApiSearchParams>): Promise<z.infer<S>> {
|
|
104
|
+
const response = await this.fetch(
|
|
105
|
+
makeApiUrl(path, this.config, {
|
|
106
|
+
culture: this.config.culture,
|
|
107
|
+
...options,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new Error(await response.json())
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return schema.parseAsync(await response.json())
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public async fetchCatalogs(): Promise<CatalogDefinition[]> {
|
|
119
|
+
return this.get(
|
|
120
|
+
['catalogs'],
|
|
121
|
+
z.array(CatalogDefinitionSchema),
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public async populateCache(catalogName: CatalogName, culture: ApiCulture): Promise<void>
|
|
126
|
+
public async populateCache(catalogName: CatalogName, culture: ApiCulture, id: number): Promise<CatalogEntryName | null>
|
|
127
|
+
public async populateCache(catalogName: CatalogName, culture: ApiCulture, id?: number): Promise<void | CatalogEntryName | null> {
|
|
128
|
+
const catalog = await this.fetchCatalog(
|
|
129
|
+
catalogName,
|
|
130
|
+
{ culture },
|
|
131
|
+
)
|
|
132
|
+
await this.cache.setEntries(
|
|
133
|
+
catalogName,
|
|
134
|
+
culture,
|
|
135
|
+
catalog,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if (id !== undefined) {
|
|
139
|
+
const queriedKey = catalog.find(({ id: entryId }) => entryId === id)
|
|
140
|
+
return queriedKey
|
|
141
|
+
? {
|
|
142
|
+
name: queriedKey.name,
|
|
143
|
+
namePlural: queriedKey.name_plurial,
|
|
144
|
+
}
|
|
145
|
+
: null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public async getCatalogEntries(catalogName: CatalogName, options?: Pick<ApiSearchParams, 'culture'>): Promise<CatalogEntry[]> {
|
|
150
|
+
try {
|
|
151
|
+
return await this.cache.getEntries(catalogName, options?.culture ?? this.config.culture)
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
if (e instanceof CacheExpiredError) {
|
|
155
|
+
await this.populateCache(catalogName, options?.culture ?? this.config.culture)
|
|
156
|
+
return this.cache.getEntries(catalogName, options?.culture ?? this.config.culture)
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
throw e
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public async fetchCatalog(catalogName: CatalogName, options?: Pick<ApiSearchParams, 'culture'>): Promise<CatalogEntry[]> {
|
|
165
|
+
return this.get(
|
|
166
|
+
['catalogs', catalogName],
|
|
167
|
+
z.array(CatalogEntrySchema),
|
|
168
|
+
options,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public async fetchAgencies(options?: Pick<ApiSearchParams, 'culture' | 'limit' | 'offset'>) {
|
|
173
|
+
return this.get(
|
|
174
|
+
['agencies'],
|
|
175
|
+
z.object({
|
|
176
|
+
total_items: z.number(),
|
|
177
|
+
agencies: getAgencySchema(this.getLocalizedCatalogTransformer(
|
|
178
|
+
options?.culture ?? this.config.culture,
|
|
179
|
+
), this.config).array(),
|
|
180
|
+
timestamp: z.number(),
|
|
181
|
+
},
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public async fetchProperties(agencyId: number, options?: Pick<ApiSearchParams, 'culture' | 'limit' | 'offset' | 'timestamp' | 'step' | 'status' | 'group'>) {
|
|
187
|
+
return this.get(
|
|
188
|
+
['agencies', agencyId.toString(), 'properties'],
|
|
189
|
+
z.object({
|
|
190
|
+
total_items: z.number(),
|
|
191
|
+
timestamp: z.number(),
|
|
192
|
+
properties: getPropertySchema(this.getLocalizedCatalogTransformer(
|
|
193
|
+
options?.culture ?? this.config.culture,
|
|
194
|
+
)).array(),
|
|
195
|
+
}),
|
|
196
|
+
options,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private getLocalizedCatalogTransformer(culture: ApiCulture): LocalizedCatalogTransformer {
|
|
201
|
+
return async (catalogName, id) => {
|
|
202
|
+
if (!this.config.catalogs.transform.active) {
|
|
203
|
+
return `${catalogName}.${id}`
|
|
204
|
+
}
|
|
205
|
+
if (this.config.catalogs.transform.transformFn) {
|
|
206
|
+
return this.config.catalogs.transform.transformFn(
|
|
207
|
+
catalogName,
|
|
208
|
+
culture,
|
|
209
|
+
id,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return this.catalogTransformer(catalogName, culture, id)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async catalogTransformer(catalogName: CatalogName, culture: ApiCulture, id: number): Promise<CatalogEntryName | null> {
|
|
218
|
+
try {
|
|
219
|
+
return await this.cache.getEntry(catalogName, culture, id)
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
if (e instanceof CacheExpiredError) {
|
|
223
|
+
return await this.populateCache(catalogName, culture, id)
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
throw e
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AdditionalConfig } from '../core/api'
|
|
2
|
+
import type { LocalizedCatalogTransformer } from './common'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { Converters } from '../core/converters'
|
|
5
|
+
import { CitySchema, getUserSchema, NameIdPairSchema } from './common'
|
|
6
|
+
|
|
7
|
+
export function getRateSchema(transformer: LocalizedCatalogTransformer) {
|
|
8
|
+
return z.object({
|
|
9
|
+
id: z.coerce.number(),
|
|
10
|
+
category: z.coerce.number().transform(v => transformer('property_category', v)),
|
|
11
|
+
range_min: z.coerce.number().nullable(),
|
|
12
|
+
range_max: z.coerce.number().nullable(),
|
|
13
|
+
commission_price: z.coerce.number().nullable(),
|
|
14
|
+
commission_rate: z.coerce.number().nullable(),
|
|
15
|
+
comment: z.string(),
|
|
16
|
+
url: z.string().nullable(),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const PartnerSchema = z.object({
|
|
21
|
+
type: z.coerce.number(),
|
|
22
|
+
partner: z.coerce.number().nullable(),
|
|
23
|
+
name: z.string().nullable(),
|
|
24
|
+
reference: z.string(),
|
|
25
|
+
amount: z.coerce.number(),
|
|
26
|
+
currency: z.string().toLowerCase(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export function getAgencySchema(transformer: LocalizedCatalogTransformer, config: AdditionalConfig) {
|
|
30
|
+
return z.object({
|
|
31
|
+
id: z.coerce.number(),
|
|
32
|
+
reference: z.coerce.number(),
|
|
33
|
+
active: z.boolean(),
|
|
34
|
+
name: z.string(),
|
|
35
|
+
company: NameIdPairSchema,
|
|
36
|
+
brand: z.unknown().nullable(),
|
|
37
|
+
networks: z.unknown().array(),
|
|
38
|
+
address: z.string(),
|
|
39
|
+
address_more: z.string().nullable(),
|
|
40
|
+
city: CitySchema,
|
|
41
|
+
district: z.unknown(),
|
|
42
|
+
country: z.string().toLowerCase(),
|
|
43
|
+
region: z.string().toLowerCase(),
|
|
44
|
+
latitude: z.coerce.number(),
|
|
45
|
+
longitude: z.coerce.number(),
|
|
46
|
+
email: z.string().email(),
|
|
47
|
+
phone: z.string(),
|
|
48
|
+
fax: z.string().nullable(),
|
|
49
|
+
url: z.string(),
|
|
50
|
+
logo: z.string().url(),
|
|
51
|
+
logo_svg: z.string().nullable(),
|
|
52
|
+
picture: z.string().url(),
|
|
53
|
+
currency: z.string().toLowerCase(),
|
|
54
|
+
timetable: z.string(),
|
|
55
|
+
created_at: z.coerce.string().transform(Converters.toDate),
|
|
56
|
+
updated_at: z.coerce.string().transform(Converters.toDate),
|
|
57
|
+
providers: z.string().transform(Converters.toUrl('agencies', config.baseUrl)),
|
|
58
|
+
rates: getRateSchema(transformer).array(),
|
|
59
|
+
partners: PartnerSchema.array(),
|
|
60
|
+
stories: z.unknown().array(),
|
|
61
|
+
users: getUserSchema(transformer).array(),
|
|
62
|
+
sectors: z.unknown().array(),
|
|
63
|
+
parameters: z.string().transform(Converters.toUrl('agencies', config.baseUrl)),
|
|
64
|
+
subscription: z.string(),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CatalogName } from '../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../consts/languages'
|
|
3
|
+
import type { CatalogEntryName } from '../services/storage/types'
|
|
4
|
+
import { unknown, z } from 'zod'
|
|
5
|
+
import { Converters } from '../core/converters'
|
|
6
|
+
|
|
7
|
+
export interface CatalogTransformer {
|
|
8
|
+
(catalogName: CatalogName, culture: ApiCulture, id: number): Promise<CatalogEntryName | string | null>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LocalizedCatalogTransformer {
|
|
12
|
+
(catalogName: CatalogName, id: number): Promise<CatalogEntryName | string | null>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CatalogDefinitionSchema = z.object({
|
|
16
|
+
name: z.string(),
|
|
17
|
+
path: z.string().url(),
|
|
18
|
+
private: z.boolean(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export type CatalogDefinition = z.infer<typeof CatalogDefinitionSchema>
|
|
22
|
+
|
|
23
|
+
export const CatalogEntrySchema = z.object({
|
|
24
|
+
id: z.number(),
|
|
25
|
+
culture: z.string().optional(),
|
|
26
|
+
name: z.string(),
|
|
27
|
+
name_plurial: z.string().optional(),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export type CatalogEntry = z.infer<typeof CatalogEntrySchema>
|
|
31
|
+
|
|
32
|
+
export const NameIdPairSchema = z.object({
|
|
33
|
+
id: z.coerce.number(),
|
|
34
|
+
name: z.string(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export const CitySchema = NameIdPairSchema.extend({
|
|
38
|
+
zipcode: z.string(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export function getUserSchema(transformer: LocalizedCatalogTransformer) {
|
|
42
|
+
return z.object({
|
|
43
|
+
id: z.coerce.number(),
|
|
44
|
+
agency: z.coerce.number(),
|
|
45
|
+
active: z.boolean(),
|
|
46
|
+
created_at: z.coerce.string().transform(Converters.toDate),
|
|
47
|
+
updated_at: z.coerce.string().transform(Converters.toDate),
|
|
48
|
+
firstname: z.string(),
|
|
49
|
+
lastname: z.string(),
|
|
50
|
+
username: z.string().optional(),
|
|
51
|
+
password: z.string().optional(),
|
|
52
|
+
language: z.string(),
|
|
53
|
+
spoken_languages: z.string().array().optional(),
|
|
54
|
+
group: z.coerce.number().transform(v => transformer('user_group', v)),
|
|
55
|
+
email: z.string().email(),
|
|
56
|
+
phone: z.string().nullable(),
|
|
57
|
+
mobile: z.string(),
|
|
58
|
+
fax: z.string().nullable(),
|
|
59
|
+
city: NameIdPairSchema.nullable().optional(),
|
|
60
|
+
birthday_at: z.coerce.string().transform(Converters.toDate),
|
|
61
|
+
timezone: z.string().nullable(),
|
|
62
|
+
picture: z.string().nullable(),
|
|
63
|
+
partners: unknown().array().optional(),
|
|
64
|
+
stories: unknown().array().optional(),
|
|
65
|
+
rates: unknown(),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const TYPE_UNDOCUMENTED = z.unknown().transform((v, { path }) => {
|
|
4
|
+
console.warn(`Unhandled \`${path}\` with value \`${v}\``)
|
|
5
|
+
return v
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export const TYPE_UNDOCUMENTED_NULLABLE = z.unknown().nullable().transform((v, { path }) => {
|
|
9
|
+
if (v !== null) {
|
|
10
|
+
console.warn(`Unhandled \`${path}\` with value \`${v}\``)
|
|
11
|
+
}
|
|
12
|
+
return v
|
|
13
|
+
})
|