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.
Files changed (73) hide show
  1. package/.github/workflows/ci.yml +37 -0
  2. package/.github/workflows/publish.yml +69 -0
  3. package/.idea/apimo.js.iml +13 -0
  4. package/.idea/copilotDiffState.xml +43 -0
  5. package/.idea/inspectionProfiles/Project_Default.xml +6 -0
  6. package/.idea/jsLinters/eslint.xml +6 -0
  7. package/.idea/modules.xml +8 -0
  8. package/.idea/prettier.xml +6 -0
  9. package/.idea/vcs.xml +6 -0
  10. package/README.md +91 -0
  11. package/dist/src/consts/catalogs.d.ts +2 -0
  12. package/dist/src/consts/catalogs.js +53 -0
  13. package/dist/src/consts/languages.d.ts +2 -0
  14. package/dist/src/consts/languages.js +20 -0
  15. package/dist/src/core/api.d.ts +389 -0
  16. package/dist/src/core/api.js +157 -0
  17. package/dist/src/core/api.test.d.ts +1 -0
  18. package/dist/src/core/api.test.js +246 -0
  19. package/dist/src/core/converters.d.ts +4 -0
  20. package/dist/src/core/converters.js +4 -0
  21. package/dist/src/schemas/agency.d.ts +416 -0
  22. package/dist/src/schemas/agency.js +61 -0
  23. package/dist/src/schemas/common.d.ts +153 -0
  24. package/dist/src/schemas/common.js +47 -0
  25. package/dist/src/schemas/internal.d.ts +3 -0
  26. package/dist/src/schemas/internal.js +11 -0
  27. package/dist/src/schemas/property.d.ts +1500 -0
  28. package/dist/src/schemas/property.js +238 -0
  29. package/dist/src/services/storage/dummy.cache.d.ts +10 -0
  30. package/dist/src/services/storage/dummy.cache.js +28 -0
  31. package/dist/src/services/storage/dummy.cache.test.d.ts +1 -0
  32. package/dist/src/services/storage/dummy.cache.test.js +96 -0
  33. package/dist/src/services/storage/filesystem.cache.d.ts +18 -0
  34. package/dist/src/services/storage/filesystem.cache.js +85 -0
  35. package/dist/src/services/storage/filesystem.cache.test.d.ts +1 -0
  36. package/dist/src/services/storage/filesystem.cache.test.js +197 -0
  37. package/dist/src/services/storage/memory.cache.d.ts +20 -0
  38. package/dist/src/services/storage/memory.cache.js +62 -0
  39. package/dist/src/services/storage/memory.cache.test.d.ts +1 -0
  40. package/dist/src/services/storage/memory.cache.test.js +80 -0
  41. package/dist/src/services/storage/types.d.ts +16 -0
  42. package/dist/src/services/storage/types.js +4 -0
  43. package/dist/src/types/index.d.ts +4 -0
  44. package/dist/src/types/index.js +1 -0
  45. package/dist/src/utils/url.d.ts +14 -0
  46. package/dist/src/utils/url.js +11 -0
  47. package/dist/src/utils/url.test.d.ts +1 -0
  48. package/dist/src/utils/url.test.js +18 -0
  49. package/dist/vitest.config.d.ts +2 -0
  50. package/dist/vitest.config.js +6 -0
  51. package/eslint.config.mjs +3 -0
  52. package/package.json +45 -0
  53. package/src/consts/catalogs.ts +55 -0
  54. package/src/consts/languages.ts +22 -0
  55. package/src/core/api.test.ts +308 -0
  56. package/src/core/api.ts +230 -0
  57. package/src/core/converters.ts +7 -0
  58. package/src/schemas/agency.ts +66 -0
  59. package/src/schemas/common.ts +67 -0
  60. package/src/schemas/internal.ts +13 -0
  61. package/src/schemas/property.ts +257 -0
  62. package/src/services/storage/dummy.cache.test.ts +110 -0
  63. package/src/services/storage/dummy.cache.ts +21 -0
  64. package/src/services/storage/filesystem.cache.test.ts +243 -0
  65. package/src/services/storage/filesystem.cache.ts +94 -0
  66. package/src/services/storage/memory.cache.test.ts +94 -0
  67. package/src/services/storage/memory.cache.ts +69 -0
  68. package/src/services/storage/types.ts +20 -0
  69. package/src/types/index.ts +5 -0
  70. package/src/utils/url.test.ts +21 -0
  71. package/src/utils/url.ts +27 -0
  72. package/tsconfig.json +13 -0
  73. 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
+ })
@@ -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,7 @@
1
+ export const Converters = {
2
+ toDate: (v: string) => new Date(v),
3
+ toUrl: (path: string, baseUrl: string) => (v: string) => new URL(
4
+ `${path}${v}`,
5
+ baseUrl,
6
+ ).href,
7
+ }
@@ -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
+ })