bcchapi 1.0.0

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/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # BCCHAPI
2
+
3
+ [Statistical Data Base API](https://si3.bcentral.cl/Siete/es/Siete/API) client of the Central Bank of Chile.
4
+
5
+ The Central Bank of Chile provides a web service that allows searching and extracting economic information contained in its Statistical Data Base (BDE). The records are displayed in a format useful for developers and analysts who wish to automate their query processes.
6
+
7
+ The API currently supports only to functions: `GetSeries` (default) used for retrieveng historical data on a specific series, and `SearchSeries` used for searching series by frequency code (ie: daily, monthly, quearterly or yearly).
8
+
9
+ For further information on how to use the API directly, please refer to the [official documentation](https://si3.bcentral.cl/estadisticas/Principal1/Web_Services/doc_en.htm).
10
+
11
+ ## Installation
12
+
13
+ ```
14
+ npm i -S bcchapi
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```javascript
20
+ // ESM
21
+ import Client from 'bcchapi';
22
+
23
+ // CommonJS
24
+ const Client = require('bcchapi');
25
+ ```
26
+
27
+ ### Authentication
28
+
29
+ To authenticate the API calls you first need to register at the [Statistical Data Base Website](https://si3.bcentral.cl/Siete/es/Siete/API) of the Central Bank of Chile, login and activate your credentials.
30
+
31
+ ```js
32
+ const client = new Client({
33
+ user: 'jdoe@example.com',
34
+ pass: 'secret',
35
+ });
36
+ ```
37
+
38
+
39
+ ### GetSeries
40
+
41
+ Allows you to retrieve historic data series.
42
+
43
+ #### Parameters
44
+
45
+ | name | type | required | description | example |
46
+ |--------|--------------------|----------|---------------------------------|------------------------|
47
+ | series | `string` | Yes | The series identifier | `'F072.EUR.USD.N.O.D'` |
48
+ | since | `string` or `Date` | No | The starting date of the series | `'2020-12-01'` |
49
+ | until | `string` or `Date` | No | The ending date of the series | `'2020-12-02'` |
50
+
51
+
52
+ > **NOTE**: The API does not implements pagination, so if you need to retrieve a large amount of data, you should use the `since` and `until` parameters to split the request into smaller chunks.
53
+
54
+ #### Example
55
+
56
+ ```js
57
+ const series = await client.getSeries({
58
+ series: 'F072.EUR.USD.N.O.D',
59
+ since: '2020-12-01',
60
+ until: '2020-12-02',
61
+ });
62
+
63
+ console.log(series);
64
+ ```
65
+
66
+ *Output*
67
+ ```js
68
+ {
69
+ seriesId: 'F072.EUR.USD.N.O.D',
70
+ description: 'Euro per US dollar',
71
+ data: [
72
+ { date: '2020-12-01', value: 0.8373 },
73
+ { date: '2020-12-02', value: 0.8304 }
74
+ ]
75
+ }
76
+ ```
77
+
78
+ ### SearchSeries
79
+
80
+ Allows you to search for series by their frequency code (ie: daily, weekly, monthly, yearly).
81
+
82
+ #### Parameters
83
+
84
+ | name | type | required | description | example |
85
+ |-----------|----------|----------|--------------------------------------------------------------------------|-----------|
86
+ | frequency | `string` | Yes | The frequency code to search (`DAILY`, `MONTHLY`, `QUARTERLY`, `ANNUAL`) | `'DAILY'` |
87
+
88
+
89
+ #### Example
90
+
91
+ ```js
92
+ import { Frequency } from 'bcchapi';
93
+
94
+ const result = await client.searchSeries({ frequency: Frequency.DAILY });
95
+
96
+ console.log(result);
97
+ ```
98
+
99
+ *Output*
100
+ ```js
101
+ [
102
+ ...
103
+ {
104
+ seriesId: 'F021.AHP.STO.N.CLP.0.D',
105
+ frequency: 'DAILY',
106
+ title: 'Time savings deposits, including those for housing',
107
+ firstObservedAt: '2011-01-03',
108
+ lastObservedAt: '2023-12-29',
109
+ updatedAt: '2024-01-08',
110
+ createdAt: '2024-01-08'
111
+ },
112
+ {
113
+ seriesId: 'F021.BMO.STO.N.CLP.0.D',
114
+ frequency: 'DAILY',
115
+ title: 'Monetary base ',
116
+ firstObservedAt: '2011-01-03',
117
+ lastObservedAt: '2023-12-29',
118
+ updatedAt: '2024-01-08',
119
+ createdAt: '2024-01-08'
120
+ },
121
+ ...
122
+ ]
123
+ ```
124
+
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "bcchapi",
3
+ "version": "1.0.0",
4
+ "description": "API para acceder al Web Service del Banco Central de Chile.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "rimraf dist && tsc",
9
+ "test": "vitest",
10
+ "coverage": "vitest --coverage"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/airarrazaval/bcchapi.git"
15
+ },
16
+ "keywords": [
17
+ "node",
18
+ "javascript",
19
+ "typescript",
20
+ "bcchapi"
21
+ ],
22
+ "author": "Alfredo Irarrazaval",
23
+ "license": "MIT",
24
+ "bugs": {
25
+ "url": "https://github.com/airarrazaval/bcchapi/issues"
26
+ },
27
+ "homepage": "https://github.com/airarrazaval/bcchapi#readme",
28
+ "devDependencies": {
29
+ "@tsconfig/node20": "^20.1.2",
30
+ "@types/node": "^20.11.5",
31
+ "@vitest/coverage-v8": "^1.2.1",
32
+ "rimraf": "^5.0.5",
33
+ "ts-node": "^10.9.2",
34
+ "typescript": "^5.3.3",
35
+ "vitest": "^1.2.1"
36
+ },
37
+ "engines": {
38
+ "node": ">=18.19.0",
39
+ "npm": ">=10.2.3"
40
+ },
41
+ "volta": {
42
+ "node": "20.11.0"
43
+ }
44
+ }
package/src/client.ts ADDED
@@ -0,0 +1,111 @@
1
+ import * as querystring from 'node:querystring';
2
+ import * as assert from 'node:assert/strict';
3
+ import {
4
+ GetSeriesResponse,
5
+ SearchSeriesResponse,
6
+ Frequency,
7
+ parseGetSeriesResponse,
8
+ parseSearchSeriesResponse,
9
+ ApiResponse,
10
+ } from './response';
11
+ import { isValidDate } from './helpers';
12
+
13
+ export type ClientConfig = {
14
+ /**
15
+ * Client username (registered email).
16
+ */
17
+ user: string;
18
+ /**
19
+ * Client password.
20
+ */
21
+ pass: string;
22
+ };
23
+
24
+ export type GetSeriesInput = {
25
+ /**
26
+ * Series identifier.
27
+ */
28
+ series: string;
29
+ /**
30
+ * First date of the range to fetch.
31
+ */
32
+ since?: string | Date;
33
+ /**
34
+ * Last date of the range to fetch.
35
+ */
36
+ until?: string | Date;
37
+ };
38
+
39
+ export type SearchSeriesInput = {
40
+ /**
41
+ * Frequency for which you want to consult the catalog of available series.
42
+ */
43
+ frequency: Frequency;
44
+ };
45
+
46
+ export class Client {
47
+ static apiURL = 'https://si3.bcentral.cl/SieteRestWS/SieteRestWS.ashx';
48
+ private username: string;
49
+ private password: string;
50
+
51
+ constructor(config: ClientConfig) {
52
+ this.username = config.user;
53
+ this.password = config.pass;
54
+ }
55
+
56
+ async request(params: Record<string, string>): Promise<ApiResponse> {
57
+ const query = querystring.stringify({
58
+ user: this.username,
59
+ pass: this.password,
60
+ ...params,
61
+ });
62
+
63
+ return (await fetch(`${Client.apiURL}?${query}`)).json() as Promise<ApiResponse>;
64
+ }
65
+
66
+ /**
67
+ * Fetches the list of observed values for a given series.
68
+ */
69
+ async getSeries(input: GetSeriesInput): Promise<GetSeriesResponse> {
70
+ const { series, since, until } = input;
71
+
72
+ assert.ok(series && typeof series === 'string', 'series must be a non-empty string');
73
+
74
+ const params: Record<string, string> = {
75
+ timeseries: series,
76
+ function: 'GetSeries',
77
+ };
78
+
79
+ if (since) {
80
+ assert.ok(isValidDate(since), '"since" is not a valid date string or Date object');
81
+ params.firstdate = typeof since === 'string' ? since : since.toISOString().slice(0, 10);
82
+ }
83
+
84
+ if (until) {
85
+ assert.ok(isValidDate(until), '"until" is not a valid date string or Date object');
86
+ params.lastdate = typeof until === 'string' ? until : until.toISOString().slice(0, 10);
87
+ }
88
+
89
+ if (params.firstdate && params.lastdate) {
90
+ assert.ok(params.firstdate <= params.lastdate, 'invalid date range');
91
+ }
92
+
93
+ return this.request(params).then(parseGetSeriesResponse);
94
+ }
95
+
96
+ /**
97
+ * Fetches the list of available series by frequency and their metadata.
98
+ */
99
+ async searchSeries(input: SearchSeriesInput): Promise<SearchSeriesResponse> {
100
+ const { frequency } = input;
101
+
102
+ assert.ok(frequency && typeof frequency === 'string', 'frequency must be a non-empty string');
103
+
104
+ const params: Record<string, string> = {
105
+ frequency,
106
+ function: 'SearchSeries',
107
+ };
108
+
109
+ return this.request(params).then(parseSearchSeriesResponse);
110
+ }
111
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { ErrorResponse } from './response';
2
+
3
+ export enum ErrorCodes {
4
+ InvalidCredentials = -5,
5
+ InvalidFrequency = -1,
6
+ InvalidSeries = -50,
7
+ InvalidDateRange = -1,
8
+ Unknown = -1,
9
+ }
10
+
11
+ export class WebServiceError extends Error {
12
+ response?: ErrorResponse;
13
+
14
+ constructor(response?: ErrorResponse, message?: string) {
15
+ super(message || response?.Descripcion || 'Unknown service error');
16
+ this.response = response;
17
+ }
18
+ }
19
+
20
+ export class InvalidFrequencyError extends WebServiceError {
21
+ constructor(response?: ErrorResponse) {
22
+ super(response, 'Invalid frequency code (must be DAILY, MONTHLY, QUARTERLY or ANNUAL)');
23
+ }
24
+ }
25
+
26
+ export class InvalidCredentialsError extends WebServiceError {
27
+ constructor(response?: ErrorResponse) {
28
+ super(response, 'Invalid username or password');
29
+ }
30
+ }
31
+
32
+ export class InvalidSeriesError extends WebServiceError {
33
+ constructor(response?: ErrorResponse) {
34
+ super(response, 'Invalid series id');
35
+ }
36
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,13 @@
1
+ export function reverseDate(date: string): string {
2
+ return date.split('-').reverse().join('-');
3
+ }
4
+
5
+ export function isValidDate(date: unknown): boolean {
6
+ if (typeof date === 'string') {
7
+ return !isNaN(Date.parse(date));
8
+ }
9
+ if (date instanceof Date) {
10
+ return !isNaN(date.getTime());
11
+ }
12
+ return false;
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { Client, ClientConfig, GetSeriesInput, SearchSeriesInput } from './client';
2
+ export { ApiResponse, ErrorResponse, Frequency, GetSeriesResponse, SearchSeriesResponse } from './response';
3
+ export { WebServiceError, InvalidCredentialsError, InvalidFrequencyError, InvalidSeriesError} from './errors';
@@ -0,0 +1,175 @@
1
+ import {
2
+ ErrorCodes,
3
+ InvalidFrequencyError,
4
+ InvalidCredentialsError,
5
+ InvalidSeriesError,
6
+ WebServiceError,
7
+ } from './errors';
8
+ import { reverseDate } from './helpers';
9
+
10
+ export enum Frequency {
11
+ Daily = 'DAILY',
12
+ Monthly = 'MONTHLY',
13
+ Quarterly = 'QUARTERLY',
14
+ Annual = 'ANNUAL',
15
+ }
16
+
17
+ interface SeriesValue {
18
+ /**
19
+ * Series observed date in DD-MM-YYYY format.
20
+ */
21
+ indexDateString: string;
22
+ /**
23
+ * Series observed value.
24
+ */
25
+ value: string;
26
+ /**
27
+ * Series observed value status code (ND = no data recorded).
28
+ */
29
+ statusCode: 'OK' | 'ND';
30
+ }
31
+
32
+ interface SeriesHistory {
33
+ /**
34
+ * Series identifier.
35
+ */
36
+ seriesId: string;
37
+ /**
38
+ * Series name in Spanish.
39
+ */
40
+ descripEsp: string;
41
+ /**
42
+ * Series name in English.
43
+ */
44
+ descripIng: string;
45
+ /**
46
+ * List of series observed values.
47
+ */
48
+ Obs: SeriesValue[];
49
+ }
50
+
51
+ interface SeriesMetadata {
52
+ /**
53
+ * Series identifier.
54
+ */
55
+ seriesId: string;
56
+ /**
57
+ * Series frequency (DAILY, MONTHLY, QUARTERLY, ANNUAL).
58
+ */
59
+ frequencyCode: Frequency;
60
+ /**
61
+ * Series name in Spanish.
62
+ */
63
+ spanishTitle: string;
64
+ /**
65
+ * Series name in English.
66
+ */
67
+ englishTitle: string;
68
+ /**
69
+ * Date of first observation in DD-MM-YYYY format.
70
+ */
71
+ firstObservation: string;
72
+ /**
73
+ * Date of last observation in DD-MM-YYYY format.
74
+ */
75
+ lastObservation: string;
76
+ /**
77
+ * Date of last update in DD-MM-YYYY format.
78
+ */
79
+ updatedAt: string;
80
+ /**
81
+ * Date of creation in DD-MM-YYYY format.
82
+ */
83
+ createdAt: string;
84
+ }
85
+
86
+ export interface ApiResponse {
87
+ /**
88
+ * Response status code.
89
+ */
90
+ Codigo: number;
91
+ /**
92
+ * Response status message.
93
+ */
94
+ Descripcion: string;
95
+
96
+ /**
97
+ * Series historic information.
98
+ */
99
+ Series: SeriesHistory | {
100
+ [key in keyof SeriesHistory]: null;
101
+ };
102
+ /**
103
+ * Series metadata information.
104
+ */
105
+ SeriesInfos: SeriesMetadata[];
106
+ }
107
+
108
+ export interface ErrorResponse extends ApiResponse {
109
+ Series: {
110
+ [key in keyof SeriesHistory]: null;
111
+ };
112
+ SeriesInfos: never[];
113
+ }
114
+
115
+ export type GetSeriesResponse = {
116
+ seriesId: string;
117
+ description: string;
118
+ data: ReadonlyArray<{ date: string; value: number; }>;
119
+ }
120
+
121
+ export type SearchSeriesResponse = ReadonlyArray<{
122
+ seriesId: string;
123
+ frequency: Frequency;
124
+ title: string;
125
+ firstObservedAt: string;
126
+ lastObservedAt: string;
127
+ updatedAt: string;
128
+ createdAt: string;
129
+ }>;
130
+
131
+ export function parseGetSeriesResponse<T extends ApiResponse>(response: T): GetSeriesResponse {
132
+ if (response.Codigo !== 0) {
133
+ switch (response.Codigo) {
134
+ case ErrorCodes.InvalidCredentials:
135
+ throw new InvalidCredentialsError();
136
+ case ErrorCodes.InvalidSeries:
137
+ throw new InvalidSeriesError();
138
+ default:
139
+ throw new WebServiceError(response as ApiResponse as ErrorResponse);
140
+ }
141
+ }
142
+
143
+ return {
144
+ seriesId: response.Series.seriesId || '',
145
+ description: response.Series.descripIng || '',
146
+ data: (response.Series.Obs || []).map((obs) => ({
147
+ date: reverseDate(obs.indexDateString),
148
+ value: parseFloat(obs.value),
149
+ })),
150
+ };
151
+ }
152
+
153
+ export function parseSearchSeriesResponse<T extends ApiResponse>(response: T): SearchSeriesResponse {
154
+ if (response.Codigo !== 0) {
155
+ switch (response.Codigo) {
156
+ case ErrorCodes.InvalidCredentials:
157
+ throw new InvalidCredentialsError();
158
+ case ErrorCodes.InvalidFrequency:
159
+ throw new InvalidFrequencyError();
160
+ default:
161
+ throw new WebServiceError(response as ApiResponse as ErrorResponse);
162
+ }
163
+ }
164
+ console.log(response);
165
+
166
+ return response.SeriesInfos.map((series) => ({
167
+ seriesId: series.seriesId,
168
+ frequency: series.frequencyCode,
169
+ title: series.englishTitle,
170
+ firstObservedAt: reverseDate(series.firstObservation),
171
+ lastObservedAt: reverseDate(series.lastObservation),
172
+ updatedAt: reverseDate(series.updatedAt),
173
+ createdAt: reverseDate(series.createdAt),
174
+ }));
175
+ }
@@ -0,0 +1,196 @@
1
+ import { expect, describe, it, vi, afterEach, afterAll, beforeEach } from 'vitest';
2
+ import { Client } from '../src';
3
+ import {
4
+ Frequency,
5
+ InvalidCredentialsError,
6
+ InvalidSeriesError,
7
+ InvalidFrequencyError,
8
+ } from '../src';
9
+ import fetchMock from './mocks/fetch.mock';
10
+ import fixtures from './fixtures';
11
+ import { reverseDate } from '../src/helpers';
12
+
13
+ // global.fetch = vi.fn().mockImplementation(fetchMock);
14
+ const fetchSpy = vi.spyOn(global, 'fetch').mockImplementation(fetchMock);
15
+
16
+ describe('Client', () => {
17
+ const client = new Client({
18
+ user: 'test',
19
+ pass: 'test',
20
+ });
21
+
22
+ const invalidClient = new Client({
23
+ user: '',
24
+ pass: '',
25
+ });
26
+
27
+ it('should create a Client instance', () => {
28
+ expect(client).toBeInstanceOf(Client);
29
+ });
30
+
31
+ describe('getSeries', () => {
32
+ beforeEach(() => {
33
+ fetchSpy.mockImplementation(fetchMock);
34
+ });
35
+ afterEach(() => {
36
+ fetchSpy.mockReset();
37
+ });
38
+
39
+ it('should throw an error if series is not a non-empty string', async () => {
40
+ await expect(client.getSeries({
41
+ series: undefined as unknown as string,
42
+ })).rejects.toThrow('series must be a non-empty string');
43
+ await expect(client.getSeries({
44
+ series: '',
45
+ })).rejects.toThrow('series must be a non-empty string');
46
+
47
+ expect(fetchSpy).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it('should throw an error if date range is invalid', async () => {
51
+ await expect(client.getSeries({
52
+ series: 'TEST',
53
+ since: 'invalid',
54
+ })).rejects.toThrow('"since" is not a valid date string or Date object');
55
+ await expect(client.getSeries({
56
+ series: 'TEST',
57
+ since: new Date('invalid'),
58
+ })).rejects.toThrow('"since" is not a valid date string or Date object');
59
+
60
+ await expect(client.getSeries({
61
+ series: 'TEST',
62
+ until: 'invalid',
63
+ })).rejects.toThrow('"until" is not a valid date string or Date object');
64
+ await expect(client.getSeries({
65
+ series: 'TEST',
66
+ until: new Date('invalid'),
67
+ })).rejects.toThrow('"until" is not a valid date string or Date object');
68
+
69
+ await expect(client.getSeries({
70
+ series: 'TEST',
71
+ since: new Date(2020, 0, 1),
72
+ until: new Date(2000, 0, 1),
73
+ })).rejects.toThrow('invalid date range');
74
+
75
+ expect(fetchSpy).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('should throw error if credentials are invalid', async () => {
79
+ await expect(invalidClient.getSeries({ series: 'TEST' })).rejects.toThrow(InvalidCredentialsError);
80
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('should throw error if series does not exist', async () => {
84
+ await expect(client.getSeries({ series: 'invalid' })).rejects.toThrow(InvalidSeriesError);
85
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ it('should return the series history information', async () => {
89
+ const series = await client.getSeries({ series: 'TEST' });
90
+
91
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
92
+
93
+ expect(series).toBeDefined();
94
+ expect(series.seriesId).toBe('TEST');
95
+ expect(series.description).toBe('Test');
96
+ expect(series.data).toBeInstanceOf(Array);
97
+ expect(series.data).toHaveLength(15);
98
+
99
+ for (let i = 0; i < series.data.length; i += 1) {
100
+ expect(reverseDate(series.data[i].date)).toBe(fixtures.response.getSeriesSuccess.Series.Obs[i].indexDateString);
101
+ expect(series.data[i].value.toFixed(2)).toBe(fixtures.response.getSeriesSuccess.Series.Obs[i].value);
102
+
103
+ if (Number.isNaN(series.data[i].value)) {
104
+ expect(fixtures.response.getSeriesSuccess.Series.Obs[i].statusCode).toBe('ND');
105
+ }
106
+ }
107
+ });
108
+
109
+ it('should filter the series history by date range', async () => {
110
+ const series = await client.getSeries({
111
+ series: 'TEST',
112
+ until: '2020-12-05',
113
+ });
114
+
115
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
116
+
117
+ expect(series).toBeDefined();
118
+ expect(series.seriesId).toBe('TEST');
119
+ expect(series.description).toBe('Test');
120
+ expect(series.data).toBeInstanceOf(Array);
121
+ expect(series.data).toHaveLength(5);
122
+
123
+ for (let i = 0; i < series.data.length; i += 1) {
124
+ expect(reverseDate(series.data[i].date)).toBe(fixtures.response.getSeriesSuccess.Series.Obs[i].indexDateString);
125
+ expect(series.data[i].value.toFixed(2)).toBe(fixtures.response.getSeriesSuccess.Series.Obs[i].value);
126
+
127
+ if (Number.isNaN(series.data[i].value)) {
128
+ expect(fixtures.response.getSeriesSuccess.Series.Obs[i].statusCode).toBe('ND');
129
+ }
130
+ }
131
+ });
132
+
133
+ it('should return empty results if series has no data', async () => {
134
+ const series = await client.getSeries({
135
+ series: 'TEST',
136
+ since: '2020-12-16',
137
+ });
138
+
139
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
140
+
141
+ expect(series).toBeDefined();
142
+ expect(series.seriesId).toBe('TEST');
143
+ expect(series.description).toBe('Test');
144
+ expect(series.data).toBeInstanceOf(Array);
145
+ expect(series.data).toHaveLength(0);
146
+ });
147
+
148
+ });
149
+
150
+ describe('searchSeries', () => {
151
+ beforeEach(() => {
152
+ fetchSpy.mockImplementation(fetchMock);
153
+ });
154
+ afterEach(() => {
155
+ fetchSpy.mockReset();
156
+ });
157
+
158
+ it('should throw an error if frequency is not a non-empty string', async () => {
159
+ await expect(client.searchSeries({
160
+ frequency: undefined as unknown as Frequency,
161
+ })).rejects.toThrow('frequency must be a non-empty string');
162
+ await expect(client.searchSeries({
163
+ frequency: '' as Frequency,
164
+ })).rejects.toThrow('frequency must be a non-empty string');
165
+
166
+ expect(fetchSpy).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('should throw error if credentials are invalid', async () => {
170
+ await expect(invalidClient.searchSeries({
171
+ frequency: Frequency.Daily,
172
+ })).rejects.toThrow(InvalidCredentialsError);
173
+
174
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
175
+ });
176
+
177
+ it('should throw error if frequency is invalid', async () => {
178
+ await expect(client.searchSeries({
179
+ frequency: 'invalid' as Frequency,
180
+ })).rejects.toThrow(InvalidFrequencyError);
181
+
182
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
183
+ });
184
+
185
+ it('should return the series metadata information', async () => {
186
+ const series = await client.searchSeries({
187
+ frequency: Frequency.Daily,
188
+ });
189
+
190
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
191
+
192
+ expect(series).toBeInstanceOf(Array);
193
+ expect(series).toHaveLength(1);
194
+ });
195
+ });
196
+ });
@@ -0,0 +1,15 @@
1
+ import getSeriesSuccess from './responses/getseries.success.json';
2
+ import getSeriesInvalid from './responses/getseries.invalid.json';
3
+ import searchSeriesSuccess from './responses/searchseries.success.json';
4
+ import searchSeriesInvalid from './responses/searchseries.invalid.json';
5
+ import credentialsInvalid from './responses/credentials.invalid.json';
6
+
7
+ export default {
8
+ response: {
9
+ credentialsInvalid,
10
+ getSeriesInvalid,
11
+ getSeriesSuccess,
12
+ searchSeriesInvalid,
13
+ searchSeriesSuccess,
14
+ },
15
+ };
@@ -0,0 +1,11 @@
1
+ {
2
+ "Codigo": -5,
3
+ "Descripcion": "Invalid username or password",
4
+ "Series": {
5
+ "descripEsp": null,
6
+ "descripIng": null,
7
+ "seriesId": null,
8
+ "Obs": null
9
+ },
10
+ "SeriesInfos": []
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "Codigo": -50,
3
+ "Descripcion": "An internal error has occurred, information is not available.",
4
+ "Series": {
5
+ "descripEsp": null,
6
+ "descripIng": null,
7
+ "seriesId": null,
8
+ "Obs": null
9
+ },
10
+ "SeriesInfos": []
11
+ }
@@ -0,0 +1,87 @@
1
+ {
2
+ "Codigo": 0,
3
+ "Descripcion": "Success",
4
+ "Series": {
5
+ "seriesId": "TEST",
6
+ "descripEsp": "Test",
7
+ "descripIng": "Test",
8
+ "Obs": [
9
+ {
10
+ "indexDateString": "01-12-2020",
11
+ "value": "12.01",
12
+ "statusCode": "OK"
13
+ },
14
+ {
15
+ "indexDateString": "02-12-2020",
16
+ "value": "12.02",
17
+ "statusCode": "OK"
18
+ },
19
+ {
20
+ "indexDateString": "03-12-2020",
21
+ "value": "12.03",
22
+ "statusCode": "OK"
23
+ },
24
+ {
25
+ "indexDateString": "04-12-2020",
26
+ "value": "12.04",
27
+ "statusCode": "OK"
28
+ },
29
+ {
30
+ "indexDateString": "05-12-2020",
31
+ "value": "NaN",
32
+ "statusCode": "ND"
33
+ },
34
+ {
35
+ "indexDateString": "06-12-2020",
36
+ "value": "NaN",
37
+ "statusCode": "ND"
38
+ },
39
+ {
40
+ "indexDateString": "07-12-2020",
41
+ "value": "12.07",
42
+ "statusCode": "OK"
43
+ },
44
+ {
45
+ "indexDateString": "08-12-2020",
46
+ "value": "NaN",
47
+ "statusCode": "ND"
48
+ },
49
+ {
50
+ "indexDateString": "09-12-2020",
51
+ "value": "12.09",
52
+ "statusCode": "OK"
53
+ },
54
+ {
55
+ "indexDateString": "10-12-2020",
56
+ "value": "12.10",
57
+ "statusCode": "OK"
58
+ },
59
+ {
60
+ "indexDateString": "11-12-2020",
61
+ "value": "12.11",
62
+ "statusCode": "OK"
63
+ },
64
+ {
65
+ "indexDateString": "12-12-2020",
66
+ "value": "NaN",
67
+ "statusCode": "ND"
68
+ },
69
+ {
70
+ "indexDateString": "13-12-2020",
71
+ "value": "NaN",
72
+ "statusCode": "ND"
73
+ },
74
+ {
75
+ "indexDateString": "14-12-2020",
76
+ "value": "12.14",
77
+ "statusCode": "OK"
78
+ },
79
+ {
80
+ "indexDateString": "15-12-2020",
81
+ "value": "12.15",
82
+ "statusCode": "OK"
83
+ }
84
+ ]
85
+ },
86
+ "SeriesInfo": []
87
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "Codigo": -1,
3
+ "Descripcion": "FrequencyCode must have a valid code (ie. DAILY, MONTHLY, QUARTERLY or ANNUAL)",
4
+ "Series": {
5
+ "descripEsp": null,
6
+ "descripIng": null,
7
+ "seriesId": null,
8
+ "Obs": null
9
+ },
10
+ "SeriesInfos": []
11
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "Codigo": 0,
3
+ "Descripcion": "Success",
4
+ "Series": {
5
+ "descripEsp": null,
6
+ "descripIng": null,
7
+ "seriesId": null,
8
+ "Obs": null
9
+ },
10
+ "SeriesInfos": [{
11
+ "seriesId": "TEST",
12
+ "frequencyCode": "DAILY",
13
+ "spanishTitle": "Test",
14
+ "englishTitle": "Test",
15
+ "firstObservation": "01-12-2020",
16
+ "lastObservation": "15-12-2020",
17
+ "updatedAt": "19-01-2024",
18
+ "createdAt": "19-01-2024"
19
+ }]
20
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { reverseDate, isValidDate } from '../src/helpers';
3
+
4
+ describe('helpers: reverseDate', () => {
5
+ it('should reverse correctly a date in DD-MM-YYYY format', () => {
6
+ const date = '01-02-2020';
7
+ const reversed = reverseDate(date);
8
+
9
+ expect(reversed).toBe('2020-02-01');
10
+ });
11
+ });
12
+
13
+ describe('helpers: isValidDate', () => {
14
+ it('should return true if the date is valid', () => {
15
+ expect(isValidDate('2020-01-01')).toBe(true);
16
+ expect(isValidDate(new Date())).toBe(true);
17
+ });
18
+
19
+ it('should return false if the date is invalid', () => {
20
+ expect(isValidDate('invalid')).toBe(false);
21
+ expect(isValidDate({})).toBe(false);
22
+ expect(isValidDate(undefined)).toBe(false);
23
+ expect(isValidDate(null)).toBe(false);
24
+ expect(isValidDate(new Date('invalid'))).toBe(false);
25
+ });
26
+ });
@@ -0,0 +1,71 @@
1
+ import { Frequency } from '../../src/response';
2
+ import { reverseDate } from '../../src/helpers';
3
+ import fixtures from '../fixtures';
4
+
5
+ export default (input: string | URL | Request) => {
6
+ const params = new URL(input.toString()).searchParams;
7
+ const func = params.get('function') || 'GetSeries';
8
+
9
+ if (params.get('user') !== 'test' || params.get('pass') !== 'test') {
10
+ return Promise.resolve({
11
+ json: () => Promise.resolve(fixtures.response.credentialsInvalid),
12
+ } as Response);
13
+ }
14
+
15
+ if (func === 'GetSeries') {
16
+ if (fixtures.response.getSeriesSuccess.Series.seriesId !== params.get('timeseries')) {
17
+ return Promise.resolve({
18
+ json: () => Promise.resolve(fixtures.response.getSeriesInvalid),
19
+ } as Response);
20
+ }
21
+
22
+ const firstDate = params.get('firstdate');
23
+ const lastDate = params.get('lastdate');
24
+ const obs = fixtures.response.getSeriesSuccess.Series.Obs.filter((obs) => {
25
+ if (firstDate && lastDate) {
26
+ return reverseDate(obs.indexDateString) >= firstDate && reverseDate(obs.indexDateString) <= lastDate;
27
+ }
28
+ if (firstDate) {
29
+ return reverseDate(obs.indexDateString) >= firstDate;
30
+ }
31
+ if (lastDate) {
32
+ return reverseDate(obs.indexDateString) <= lastDate;
33
+ }
34
+ return true;
35
+ });
36
+ const series = {
37
+ ...fixtures.response.getSeriesSuccess.Series,
38
+ Obs: obs,
39
+ };
40
+
41
+ return Promise.resolve({
42
+ json: () => Promise.resolve({
43
+ ...fixtures.response.getSeriesSuccess,
44
+ Series: series,
45
+ }),
46
+ } as Response);
47
+ }
48
+
49
+ if (func === 'SearchSeries') {
50
+ const frequency = params.get('frequency');
51
+
52
+ if (!Object.values(Frequency).includes(frequency as Frequency)) {
53
+ return Promise.resolve({
54
+ json: () => Promise.resolve(fixtures.response.searchSeriesInvalid),
55
+ } as Response);
56
+ }
57
+
58
+ const response = {
59
+ ...fixtures.response.searchSeriesSuccess,
60
+ SeriesInfo: fixtures.response.searchSeriesSuccess.SeriesInfos.filter((series) => series.frequencyCode === frequency),
61
+ };
62
+
63
+ return Promise.resolve({
64
+ json: () => Promise.resolve(response),
65
+ } as Response);
66
+ }
67
+
68
+ return Promise.resolve({
69
+ json: () => Promise.resolve({}),
70
+ } as Response);
71
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as Errors from '../src/errors';
3
+ import {
4
+ ApiResponse,
5
+ parseGetSeriesResponse,
6
+ parseSearchSeriesResponse,
7
+ } from '../src/response';
8
+ import fixtures from './fixtures';
9
+
10
+ describe('parseGetSeriesResponse', () => {
11
+ it('should parse correctly a valid response', () => {
12
+ const parsed = parseGetSeriesResponse(fixtures.response.getSeriesSuccess as unknown as ApiResponse);
13
+
14
+ expect(parsed).toBeDefined();
15
+ expect(parsed.seriesId).toBe(fixtures.response.getSeriesSuccess.Series.seriesId);
16
+ expect(parsed.description).toBe(fixtures.response.getSeriesSuccess.Series.descripIng);
17
+ expect(parsed.data).toHaveLength(fixtures.response.getSeriesSuccess.Series.Obs.length);
18
+
19
+ for (let i = 0; i < parsed.data.length; i += 1) {
20
+ expect(parsed.data[i].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
21
+ expect(parsed.data[i].value.toFixed(2)).toBe(fixtures.response.getSeriesSuccess.Series.Obs[i].value);
22
+
23
+ if (Number.isNaN(parsed.data[i].value)) {
24
+ expect(fixtures.response.getSeriesSuccess.Series.Obs[i].statusCode).toBe('ND');
25
+ }
26
+ }
27
+ });
28
+
29
+ it('should throw an InvalidCredentialsError if the response code is -5', () => {
30
+ expect(() => parseGetSeriesResponse(fixtures.response.credentialsInvalid as unknown as ApiResponse)).toThrow(Errors.InvalidCredentialsError);
31
+ });
32
+
33
+ it('should throw an InvalidSeriesError if the response code is -50', () => {
34
+ expect(() => parseGetSeriesResponse(fixtures.response.getSeriesInvalid as unknown as ApiResponse)).toThrow(Errors.InvalidSeriesError);
35
+ });
36
+
37
+ it('should throw a ResponseError if the response code is unknown', () => {
38
+ expect(() => parseGetSeriesResponse({} as ApiResponse)).toThrow(Errors.WebServiceError);
39
+ });
40
+ });
41
+
42
+ describe('parseSearchSeriesResponse', () => {
43
+ it('should parse correctly a valid response', () => {
44
+ const parsed = parseSearchSeriesResponse(fixtures.response.searchSeriesSuccess as unknown as ApiResponse);
45
+
46
+ expect(parsed).toBeInstanceOf(Array);
47
+ expect(parsed).toHaveLength(fixtures.response.searchSeriesSuccess.SeriesInfos.length);
48
+
49
+ for (let i = 0; i < parsed.length; i += 1) {
50
+ expect(parsed[i].seriesId).toBe(fixtures.response.searchSeriesSuccess.SeriesInfos[i].seriesId);
51
+ expect(parsed[i].frequency).toBe(fixtures.response.searchSeriesSuccess.SeriesInfos[i].frequencyCode);
52
+ expect(parsed[i].title).toBe(fixtures.response.searchSeriesSuccess.SeriesInfos[i].englishTitle);
53
+ expect(parsed[i].firstObservedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
54
+ expect(parsed[i].lastObservedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
55
+ expect(parsed[i].updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
56
+ expect(parsed[i].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
57
+ }
58
+ });
59
+
60
+ it('should throw an InvalidCredentialsError if the response code is -5', () => {
61
+ expect(() => parseSearchSeriesResponse(fixtures.response.credentialsInvalid as unknown as ApiResponse)).toThrow(Errors.InvalidCredentialsError);
62
+ });
63
+
64
+ it('should throw an InvalidFrequencyError if the response code is -1', () => {
65
+ expect(() => parseSearchSeriesResponse(fixtures.response.searchSeriesInvalid as unknown as ApiResponse)).toThrow(Errors.InvalidFrequencyError);
66
+ });
67
+
68
+ it('should throw a ResponseError if the response code is unknown', () => {
69
+ expect(() => parseSearchSeriesResponse({} as ApiResponse)).toThrow(Errors.WebServiceError);
70
+ });
71
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@tsconfig/node20",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "resolveJsonModule": true,
6
+ "declaration": true,
7
+ },
8
+ "include": ["src/**/*"]
9
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ dir: 'test',
8
+ },
9
+ });