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,94 @@
|
|
|
1
|
+
import type { CatalogName } from '../../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../../consts/languages'
|
|
3
|
+
import type { CatalogEntry } from '../../schemas/common'
|
|
4
|
+
import type { ApiCacheAdapter, CatalogEntryName } from './types'
|
|
5
|
+
import { mkdirSync } from 'node:fs'
|
|
6
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
7
|
+
import * as path from 'node:path'
|
|
8
|
+
import { CacheExpiredError } from './types'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_FILESYSTEM_CACHE_LOCATION = './cache/catalogs'
|
|
11
|
+
const MS_IN_ONE_WEEK = 7 * 24 * 60 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
export class FilesystemCache implements ApiCacheAdapter {
|
|
14
|
+
private readonly path: string
|
|
15
|
+
private readonly cacheExpirationMs: number
|
|
16
|
+
|
|
17
|
+
constructor(settings?: { path?: string, cacheExpirationMs?: number }) {
|
|
18
|
+
this.path = settings?.path ?? DEFAULT_FILESYSTEM_CACHE_LOCATION
|
|
19
|
+
this.cacheExpirationMs = settings?.cacheExpirationMs ?? MS_IN_ONE_WEEK
|
|
20
|
+
|
|
21
|
+
mkdirSync(this.path, { recursive: true })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async setEntries(catalogName: CatalogName, culture: ApiCulture, entries: CatalogEntry[]): Promise<void> {
|
|
25
|
+
const filePath = this.getCacheFilePath(catalogName, culture)
|
|
26
|
+
const formattedEntries = Object.fromEntries(
|
|
27
|
+
entries.map<[
|
|
28
|
+
string,
|
|
29
|
+
CatalogEntryName,
|
|
30
|
+
]>(({ id, name, name_plurial }) => [id.toString(), {
|
|
31
|
+
name,
|
|
32
|
+
namePlural: name_plurial,
|
|
33
|
+
}]),
|
|
34
|
+
)
|
|
35
|
+
const dump = JSON.stringify({
|
|
36
|
+
timestamp: Date.now(),
|
|
37
|
+
cache: formattedEntries,
|
|
38
|
+
})
|
|
39
|
+
return writeFile(filePath, dump)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async readFileOrThrow(filePath: string): Promise<string> {
|
|
43
|
+
try {
|
|
44
|
+
return await readFile(filePath, 'utf-8')
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new CacheExpiredError()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getEntry(catalogName: CatalogName, culture: ApiCulture, id: number): Promise<CatalogEntryName | null> {
|
|
52
|
+
const filePath = this.getCacheFilePath(catalogName, culture)
|
|
53
|
+
const data = await this.readFileOrThrow(filePath)
|
|
54
|
+
const parsed: {
|
|
55
|
+
timestamp: number
|
|
56
|
+
cache: { [id: string]: CatalogEntryName | undefined }
|
|
57
|
+
} = JSON.parse(data)
|
|
58
|
+
|
|
59
|
+
const currentTimestamp = Date.now()
|
|
60
|
+
if (parsed.timestamp + this.cacheExpirationMs < currentTimestamp) {
|
|
61
|
+
throw new CacheExpiredError()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return parsed.cache[id.toString()] ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getEntries(catalogName: CatalogName, culture: ApiCulture): Promise<CatalogEntry[]> {
|
|
68
|
+
const filePath = this.getCacheFilePath(catalogName, culture)
|
|
69
|
+
const data = await this.readFileOrThrow(filePath)
|
|
70
|
+
const parsed: {
|
|
71
|
+
timestamp: number
|
|
72
|
+
cache: { [id: string]: CatalogEntryName | undefined }
|
|
73
|
+
} = JSON.parse(data)
|
|
74
|
+
|
|
75
|
+
const currentTimestamp = Date.now()
|
|
76
|
+
if (parsed.timestamp + this.cacheExpirationMs < currentTimestamp) {
|
|
77
|
+
throw new CacheExpiredError()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Object.entries(parsed.cache).map(([id, entry]) => ({
|
|
81
|
+
id: Number.parseInt(id, 10),
|
|
82
|
+
name: entry?.name ?? 'missing',
|
|
83
|
+
name_plurial: entry?.namePlural,
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private getCacheFilePath(catalogName: CatalogName, culture: ApiCulture) {
|
|
88
|
+
return path.join(this.path, this.getCacheFileName(catalogName, culture))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private getCacheFileName(catalogName: CatalogName, culture: ApiCulture) {
|
|
92
|
+
return `${catalogName}-${culture}.json`
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { CatalogName } from '../../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../../consts/languages'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { MemoryCache } from './memory.cache'
|
|
5
|
+
|
|
6
|
+
describe('cache - Memory', () => {
|
|
7
|
+
describe('constructor', () => {
|
|
8
|
+
it('should use default expiration time when no settings provided', () => {
|
|
9
|
+
const defaultCache = new MemoryCache()
|
|
10
|
+
expect(defaultCache.cacheExpirationMs).toBe(7 * 24 * 60 * 60 * 1000)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should use custom expiration time when provided', () => {
|
|
14
|
+
const customExpiration = 60000
|
|
15
|
+
const customCache = new MemoryCache({ cacheExpirationMs: customExpiration })
|
|
16
|
+
expect(customCache.cacheExpirationMs).toBe(customExpiration)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('setEntries and getEntry', () => {
|
|
21
|
+
const culture: ApiCulture = 'en'
|
|
22
|
+
const entries = [
|
|
23
|
+
{ id: 1, name: 'Item 1', name_plurial: 'Items 1' },
|
|
24
|
+
{ id: 2, name: 'Item 2', name_plurial: 'Items 2' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
it('should store and retrieve entries correctly', async () => {
|
|
28
|
+
const cache = new MemoryCache()
|
|
29
|
+
const catalogName: CatalogName = 'book_step'
|
|
30
|
+
await cache.setEntries(catalogName, culture, entries)
|
|
31
|
+
|
|
32
|
+
const entry1 = await cache.getEntry(catalogName, culture, 1)
|
|
33
|
+
const entry2 = await cache.getEntry(catalogName, culture, 2)
|
|
34
|
+
|
|
35
|
+
expect(entry1).toEqual({ name: 'Item 1', namePlural: 'Items 1' })
|
|
36
|
+
expect(entry2).toEqual({ name: 'Item 2', namePlural: 'Items 2' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should return null for non-existent entry', async () => {
|
|
40
|
+
const cache = new MemoryCache()
|
|
41
|
+
const catalogName: CatalogName = 'book_step'
|
|
42
|
+
await cache.setEntries(catalogName, culture, entries)
|
|
43
|
+
|
|
44
|
+
const entry = await cache.getEntry(catalogName, culture, 999)
|
|
45
|
+
expect(entry).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should throw CacheExpiredError when cache entry does not exist', async () => {
|
|
49
|
+
const cache = new MemoryCache()
|
|
50
|
+
const catalogName: CatalogName = 'book_step'
|
|
51
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrowError()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should throw CacheExpiredError when cache has expired', async () => {
|
|
55
|
+
const catalogName: CatalogName = 'book_step'
|
|
56
|
+
const expiredCache = new MemoryCache({ cacheExpirationMs: 1 })
|
|
57
|
+
await expiredCache.setEntries(catalogName, culture, entries)
|
|
58
|
+
|
|
59
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
60
|
+
|
|
61
|
+
await expect(expiredCache.getEntry(catalogName, culture, 1)).rejects.toThrowError()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should handle different catalog and culture combinations', async () => {
|
|
65
|
+
const cache = new MemoryCache()
|
|
66
|
+
await cache.setEntries('book_step', 'en', entries)
|
|
67
|
+
await cache.setEntries('property_land', 'fr', entries)
|
|
68
|
+
|
|
69
|
+
const entry1 = await cache.getEntry('book_step', 'en', 1)
|
|
70
|
+
const entry2 = await cache.getEntry('property_land', 'fr', 1)
|
|
71
|
+
|
|
72
|
+
expect(entry1).toEqual({ name: 'Item 1', namePlural: 'Items 1' })
|
|
73
|
+
expect(entry2).toEqual({ name: 'Item 1', namePlural: 'Items 1' })
|
|
74
|
+
|
|
75
|
+
await expect(cache.getEntry('book_step', 'fr', 1)).rejects.toThrowError()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should overwrite existing cache when setting new entries', async () => {
|
|
79
|
+
const cache = new MemoryCache()
|
|
80
|
+
const catalogName: CatalogName = 'property_land'
|
|
81
|
+
|
|
82
|
+
await cache.setEntries(catalogName, culture, entries)
|
|
83
|
+
|
|
84
|
+
const newEntries = [{ id: 3, name: 'New Item', name_plurial: 'New Items' }]
|
|
85
|
+
await cache.setEntries(catalogName, culture, newEntries)
|
|
86
|
+
|
|
87
|
+
const newEntry = await cache.getEntry(catalogName, culture, 3)
|
|
88
|
+
expect(newEntry).toEqual({ name: 'New Item', namePlural: 'New Items' })
|
|
89
|
+
|
|
90
|
+
const oldEntry = await cache.getEntry(catalogName, culture, 1)
|
|
91
|
+
expect(oldEntry).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { CatalogName } from '../../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../../consts/languages'
|
|
3
|
+
import type { CatalogEntry } from '../../schemas/common'
|
|
4
|
+
import type { ApiCacheAdapter, CatalogEntryName } from './types'
|
|
5
|
+
import { CacheExpiredError } from './types'
|
|
6
|
+
|
|
7
|
+
const MS_IN_ONE_WEEK = 7 * 24 * 60 * 60 * 1000
|
|
8
|
+
|
|
9
|
+
type Memory = Map<string, {
|
|
10
|
+
timestamp: number
|
|
11
|
+
cache: Map<number, CatalogEntryName>
|
|
12
|
+
}>
|
|
13
|
+
|
|
14
|
+
export class MemoryCache implements ApiCacheAdapter {
|
|
15
|
+
readonly cacheExpirationMs: number
|
|
16
|
+
readonly _MEMORY: Memory
|
|
17
|
+
|
|
18
|
+
constructor(settings?: { cacheExpirationMs?: number }) {
|
|
19
|
+
this.cacheExpirationMs = settings?.cacheExpirationMs ?? MS_IN_ONE_WEEK
|
|
20
|
+
this._MEMORY = new Map()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async setEntries(catalogName: CatalogName, culture: ApiCulture, entries: CatalogEntry[]): Promise<void> {
|
|
24
|
+
const memoryEntry = new Map(
|
|
25
|
+
entries.map<[number, CatalogEntryName]>(({ id, name, name_plurial }) => [id, {
|
|
26
|
+
name,
|
|
27
|
+
namePlural: name_plurial,
|
|
28
|
+
}]),
|
|
29
|
+
)
|
|
30
|
+
this._MEMORY.set(this.getCacheKey(catalogName, culture), {
|
|
31
|
+
timestamp: Date.now(),
|
|
32
|
+
cache: memoryEntry,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getEntry(catalogName: CatalogName, culture: ApiCulture, id: number) {
|
|
37
|
+
const memoryEntry = this._MEMORY.get(this.getCacheKey(catalogName, culture))
|
|
38
|
+
|
|
39
|
+
if (!memoryEntry) {
|
|
40
|
+
throw new CacheExpiredError()
|
|
41
|
+
}
|
|
42
|
+
if (memoryEntry.timestamp + this.cacheExpirationMs < Date.now()) {
|
|
43
|
+
throw new CacheExpiredError()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return memoryEntry.cache.get(id) ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getEntries(catalogName: CatalogName, culture: ApiCulture): Promise<CatalogEntry[]> {
|
|
50
|
+
const memoryEntry = this._MEMORY.get(this.getCacheKey(catalogName, culture))
|
|
51
|
+
|
|
52
|
+
if (!memoryEntry) {
|
|
53
|
+
throw new CacheExpiredError()
|
|
54
|
+
}
|
|
55
|
+
if (memoryEntry.timestamp + this.cacheExpirationMs < Date.now()) {
|
|
56
|
+
throw new CacheExpiredError()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Array.from(memoryEntry.cache.entries()).map(([id, { name, namePlural }]) => ({
|
|
60
|
+
id,
|
|
61
|
+
name,
|
|
62
|
+
name_plurial: namePlural,
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private getCacheKey(catalogName: CatalogName, culture: ApiCulture) {
|
|
67
|
+
return `${catalogName}.${culture}`
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CatalogName } from '../../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../../consts/languages'
|
|
3
|
+
import type { CatalogEntry } from '../../schemas/common'
|
|
4
|
+
|
|
5
|
+
export interface CatalogEntryName {
|
|
6
|
+
name: string
|
|
7
|
+
namePlural: string | undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ApiCacheAdapter {
|
|
11
|
+
setEntries: (catalogName: CatalogName, culture: ApiCulture, entries: CatalogEntry[]) => Promise<void>
|
|
12
|
+
getEntry: (catalogName: CatalogName, culture: ApiCulture, id: number) => Promise<CatalogEntryName | null>
|
|
13
|
+
getEntries: (catalogName: CatalogName, culture: ApiCulture) => Promise<CatalogEntry[]>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class CacheExpiredError extends Error {
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class NotInCacheError extends Error {
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { DEFAULT_ADDITIONAL_CONFIG } from '../core/api'
|
|
3
|
+
import { makeApiUrl } from './url'
|
|
4
|
+
|
|
5
|
+
const CONFIG = DEFAULT_ADDITIONAL_CONFIG
|
|
6
|
+
describe('url util', () => {
|
|
7
|
+
it('should, given an array of parts return a complete url', () => {
|
|
8
|
+
const url = makeApiUrl(['hello', 'world'], CONFIG)
|
|
9
|
+
expect(url.href).toBe('https://api.apimo.pro/hello/world')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('should, given an array of parts and search params return a complete url', () => {
|
|
13
|
+
const url = makeApiUrl(['hello', 'world'], CONFIG, { culture: 'fr', limit: 10 })
|
|
14
|
+
expect(url.href).toBe('https://api.apimo.pro/hello/world?culture=fr&limit=10')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should, given an array of parts and search params with undefined values, ignore them', () => {
|
|
18
|
+
const url = makeApiUrl(['hello', 'world'], CONFIG, { culture: 'fr', limit: undefined })
|
|
19
|
+
expect(url.href).toBe('https://api.apimo.pro/hello/world?culture=fr')
|
|
20
|
+
})
|
|
21
|
+
})
|
package/src/utils/url.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ApiCulture } from '../consts/languages'
|
|
2
|
+
import type { AdditionalConfig } from '../core/api'
|
|
3
|
+
import type { LACK_OF_DOCUMENTATION } from '../types'
|
|
4
|
+
|
|
5
|
+
export interface ApiSearchParams {
|
|
6
|
+
culture?: ApiCulture
|
|
7
|
+
limit?: number
|
|
8
|
+
offset?: number
|
|
9
|
+
timestamp?: number
|
|
10
|
+
step?: LACK_OF_DOCUMENTATION
|
|
11
|
+
status?: LACK_OF_DOCUMENTATION
|
|
12
|
+
group?: LACK_OF_DOCUMENTATION
|
|
13
|
+
|
|
14
|
+
[key: string]: string | number | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function makeApiUrl(pathParts: string[], config: AdditionalConfig, searchParams?: Partial<ApiSearchParams>): URL {
|
|
18
|
+
const url = new URL(pathParts.join('/'), config.baseUrl)
|
|
19
|
+
if (searchParams) {
|
|
20
|
+
Object.entries(searchParams).forEach(([key, value]) => {
|
|
21
|
+
if (value !== undefined) {
|
|
22
|
+
url.searchParams.append(key, value.toString())
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
return url
|
|
27
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2015",
|
|
4
|
+
"module": "es2015",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
}
|
|
13
|
+
}
|