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,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,5 @@
1
+ export type LACK_OF_DOCUMENTATION = string
2
+
3
+ export type DeepPartial<T> = T extends object ? {
4
+ [P in keyof T]?: DeepPartial<T[P]>;
5
+ } : T
@@ -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
+ })
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ setupFiles: ['dotenv/config'],
6
+ },
7
+ })