Skip to content

Commit 392d554

Browse files
v1.0.0
1 parent 091406b commit 392d554

File tree

7 files changed

+257
-62
lines changed

7 files changed

+257
-62
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,4 @@ dist
104104
.tern-port
105105

106106
nodemon.json
107-
test.js
107+
test.*

README.md

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,48 @@
1-
# example-api-client
1+
# fivaldi-api-client
22

3-
**ExampleApiClient** is a third party [Example API](https://example.com/docs/) client for NodeJS. It is a wrapper around an API client that has been [automatically generated](https://www.npmjs.com/package/swagger-typescript-api) using the [OpenAPI schema](https://example.com/openapi.json) provided by Example.
3+
**Fivaldi Api Client** is a third party [Fivaldi API](https://manuals.fivaldi.net/customer/api/index.html) client for NodeJS. It is a wrapper around an API client that has been [automatically generated](https://www.npmjs.com/package/swagger-typescript-api) using the [OpenAPI schema](https://manuals.fivaldi.net/customer/api/swagger.yaml) provided by Fivaldi.
44

55
## Installation
66

77
Add to project's package.json:
88

99
```
10-
npm install @rantalainen/example-api-client
10+
npm install @rantalainen/fivaldi-api-client
1111
```
1212

1313
### Import
1414

15-
```javascript
16-
import { ExampleApiClient } from '@rantalainen/example-api-client';
15+
```typescript
16+
import { FivaldiApiClient } from '@rantalainen/fivaldi-api-client';
1717
```
1818

1919
## Setup client with options
2020

21-
In order to obtain an API key, please contact Example Support. An API key is needed to access all API functions.
21+
In order to obtain partner ID and partner secret, please contact Fivaldi Support. Partner id and partner secret are needed to access API functions. More information from [Fivaldi docs](https://support.fivaldi.fi/support/solutions/articles/77000567542-fivaldi-api).
2222

23-
```javascript
24-
const example = new ExampleApiClient(
23+
```typescript
24+
const fivaldi = new FivaldiApiClient(
2525
{
26-
apiKey: 'api_key'
26+
partnerId: 'yourPartnerId',
27+
partnerSecret: 'yourPartnerSecret',
28+
// Optional arguments related to rate limiting
29+
replenishRate: 2,
30+
burstCapacity: 10
2731
},
2832
{
29-
baseURL: 'https://dev.example.com'
33+
// Optional config options
34+
baseURL: 'https://api.fivaldi.net/customer/api',
35+
timeout: 120000,
36+
keepAliveAgent: true,
37+
dnsCache: true
3038
}
3139
);
3240
```
3341

34-
Available methods can be found in the [API documentation](https://example.com/docs/).
42+
Available methods can be found in the [API documentation](https://manuals.fivaldi.net/customer/api/swagger.yaml).
3543

3644
## Resources
3745

38-
- Example: https://example.com/
39-
- Example Developer Guide: https://example.com/docs/
46+
- Fivaldi website: https://www.visma.fi/visma-fivaldi/
47+
- Fivaldi API Documentation: https://support.fivaldi.fi/support/solutions/articles/77000567542-fivaldi-api
48+
- Fivaldi API Documentation (swagger): https://manuals.fivaldi.net/customer/api/index.html

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@rantalainen/fivaldi-api-client",
33
"version": "1.0.0",
4-
"description": "",
4+
"description": "Third party Fivaldi API client for NodeJS",
55
"main": "dist/index.js",
66
"scripts": {
77
"dev": "tsc --watch",
@@ -33,6 +33,6 @@
3333
"typescript": "^4.9.5"
3434
},
3535
"publishConfig": {
36-
"access": "restricted"
36+
"access": "public"
3737
}
3838
}

src/index.ts

Lines changed: 63 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Api, ApiConfig } from './api';
2-
import { AxiosRequestConfig } from 'axios';
3-
import { HttpsAgent } from 'agentkeepalive';
2+
import { addAuthHeaders } from './signature';
3+
import { RateLimiter } from './rate-limiter';
44
import { FivaldiApiClientConfig, FivaldiApiClientOptions } from './interfaces';
5-
import { FileBuffer } from './file-buffer';
6-
import https from 'https';
5+
import { InternalAxiosRequestConfig } from 'axios';
6+
import { HttpsAgent } from 'agentkeepalive';
77
import CacheableLookup from 'cacheable-lookup';
8-
import FormData from 'form-data';
8+
import https from 'https';
99

1010
// DNS cache to prevent ENOTFOUND and other such issues
1111
const dnsCache = new CacheableLookup();
@@ -25,10 +25,14 @@ export class FivaldiApiClient {
2525
options: FivaldiApiClientOptions;
2626
config: Omit<FivaldiApiClientConfig, 'keepAliveAgent' | 'dnsCache'>;
2727
readonly api: FivaldiApiClientInstance;
28+
private rateLimiter: RateLimiter;
2829

2930
constructor(options: FivaldiApiClientOptions, config: FivaldiApiClientConfig = {}) {
30-
// Set default config
31-
config.baseURL = config.baseURL || 'hhttps://api.fivaldi.net/customer/api';
31+
// Set config or use default values
32+
config.baseURL = config.baseURL || 'https://api.fivaldi.net/customer/api';
33+
// Make sure that the base URL does not end with a slash
34+
if (config.baseURL.endsWith('/')) config.baseURL = config.baseURL.slice(0, -1);
35+
3236
config.timeout = config.timeout || 120000;
3337

3438
if (!options.partnerId || !options.partnerSecret) {
@@ -64,65 +68,78 @@ export class FivaldiApiClient {
6468
this.options = options;
6569
this.config = config;
6670

67-
// Initialize Example Api Client Instance
71+
// Initialize rate limiter with default values (will be updated from API responses)
72+
this.rateLimiter = new RateLimiter(options.replenishRate, options.burstCapacity);
73+
74+
// Initialize Fivaldi Api Client Instance
6875
this.api = new FivaldiApiClientInstance({
69-
...this.config,
70-
securityWorker: this.config.securityWorker || this.securityWorker
76+
...this.config
7177
});
72-
this.api.setSecurityData(this);
78+
79+
// Install rate limiter interceptor
80+
this.installRateLimiter();
7381

7482
// Install axios error handler
7583
this.installErrorHandler();
84+
85+
// Install security worker
86+
this.installRequestSigner();
87+
}
88+
89+
// Create a rate limiter interceptor that waits for tokens before allowing requests
90+
private installRateLimiter() {
91+
this.api.instance.interceptors.request.use(async (axiosRequestConfig: InternalAxiosRequestConfig) => {
92+
// Wait for rate limiter to allow the request
93+
await this.rateLimiter.waitUntilAvailable();
94+
return axiosRequestConfig;
95+
});
96+
}
97+
98+
// Create a custom security worker installer that adds the Authorization header to every request
99+
private installRequestSigner() {
100+
this.api.instance.interceptors.request.use(async (axiosRequestConfig: InternalAxiosRequestConfig) => {
101+
// Call the auth function to get the auth headers
102+
const authHeaders = await addAuthHeaders(this.options.partnerId, this.options.partnerSecret, axiosRequestConfig);
103+
// Add auth headers to the request config
104+
Object.entries(authHeaders).forEach(([key, value]) => {
105+
axiosRequestConfig.headers.set(key, value);
106+
});
107+
108+
return axiosRequestConfig;
109+
});
76110
}
77111

78112
private installErrorHandler() {
79113
this.api.instance.interceptors.response.use(
80-
(response) => response,
114+
(response) => {
115+
// Update rate limiter from response headers
116+
if (response.headers) {
117+
this.rateLimiter.updateFromHeaders(response.headers as Record<string, string | number>);
118+
}
119+
return response;
120+
},
81121
(error) => {
82-
error.message = `HTTP error ${error.response.status} (${error.response.statusText}): ` + JSON.stringify(error.response.data);
122+
// Update rate limiter from error response headers if available
123+
if (error.response?.headers) {
124+
this.rateLimiter.updateFromHeaders(error.response.headers as Record<string, string | number>);
125+
}
126+
127+
if (error.response) {
128+
error.message =
129+
`Fivaldi HTTP error ${error.response.status} (${error.response.statusText}): ` + JSON.stringify(error.response.data);
130+
}
131+
83132
throw error;
84133
}
85134
);
86135
}
87-
88-
private async securityWorker(fivaldi: FivaldiApiClient) {
89-
const axiosRequestConfig: AxiosRequestConfig = {};
90-
91-
axiosRequestConfig.headers = {
92-
Authorization: ''
93-
};
94-
95-
return axiosRequestConfig;
96-
}
97136
}
98137

99138
class FivaldiApiClientInstance extends Api<any> {
100139
constructor(config?: ApiConfig<any>) {
101140
super(config);
102141
}
103142

104-
// Override createFormData because FormData needs to be imported manually
105-
protected createFormData(input: Record<string, unknown>): any {
106-
return Object.keys(input || {}).reduce((formData, key) => {
107-
const property = input[key];
108-
const propertyContent: any[] = property instanceof Array ? property : [property];
109-
110-
for (const formItem of propertyContent) {
111-
const isFileType = formItem instanceof FileBuffer;
112-
113-
if (isFileType) {
114-
formData.append(key, formItem.buffer, {
115-
filename: formItem.name,
116-
contentType: formItem.type
117-
});
118-
} else {
119-
formData.append(key, this.stringifyFormItem(formItem));
120-
}
121-
}
122-
123-
return formData;
124-
}, new FormData());
125-
}
126-
143+
// If you need to add custom methods or properties, do it here
127144
helpers = {};
128145
}

src/interfaces.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export interface FivaldiApiClientOptions {
77
partnerId: string;
88
/** Partner secret that you get from Fivaldi */
99
partnerSecret: string;
10+
/** Rate limit replenish rate. This will automatically update from the first response header. */
11+
replenishRate?: number;
12+
/** Rate limit burst capacity. This will automatically update from the first response header. */
13+
burstCapacity?: number;
1014
}
1115

1216
export interface FivaldiApiClientConfig extends ApiConfig<any> {

src/rate-limiter.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
export class RateLimiter {
2+
/** Amount of current tokens */
3+
private tokens: number;
4+
/** Timestamp of the last refill */
5+
private lastRefill: number;
6+
/** Rate at which tokens are replenished (tokens per second) */
7+
private replenishRate: number;
8+
/** Maximum burst capacity (max tokens that can be used in one second) */
9+
private burstCapacity: number;
10+
11+
/**
12+
* Creates a new RateLimiter instance.
13+
* @param replenishRate Rate at which tokens are replenished (tokens per second).
14+
* This is updated from API headers after each request.
15+
* Default is 2 tokens per second.
16+
* @param burstCapacity Maximum burst capacity (max tokens that can be used in one second).
17+
* This is updated from API headers after each request.
18+
* Default is 10 tokens.
19+
*/
20+
constructor(replenishRate = 2, burstCapacity = 10) {
21+
this.replenishRate = replenishRate;
22+
this.burstCapacity = burstCapacity;
23+
this.tokens = burstCapacity; // start full
24+
this.lastRefill = Date.now();
25+
}
26+
27+
private refillTokens() {
28+
const now = Date.now();
29+
const elapsedSeconds = (now - this.lastRefill) / 1000;
30+
const tokensToAdd = elapsedSeconds * this.replenishRate;
31+
32+
this.tokens = Math.min(this.tokens + tokensToAdd, this.burstCapacity);
33+
this.lastRefill = now;
34+
}
35+
36+
/**
37+
* Waits until the rate limiter has enough tokens available to proceed.
38+
* @returns A promise that resolves when the rate limiter has enough tokens available to proceed.
39+
*/
40+
public async waitUntilAvailable(): Promise<void> {
41+
while (true) {
42+
this.refillTokens();
43+
44+
if (this.tokens >= 3) {
45+
this.tokens -= 1;
46+
return;
47+
}
48+
49+
const timeToNextTokenMs = 1000 / this.replenishRate;
50+
await new Promise((resolve) => setTimeout(resolve, timeToNextTokenMs));
51+
}
52+
}
53+
54+
/**
55+
* Updates the rate limiter's settings based on the headers from the API response.
56+
* @param headers Headers from the API response to update the rate limiter's settings.
57+
*/
58+
public updateFromHeaders(headers: Record<string, string | number | undefined>) {
59+
const newRate = parseFloat(headers['x-ratelimit-replenish-rate'] as string);
60+
const newBurst = parseFloat(headers['x-ratelimit-burst-capacity'] as string);
61+
const remaining = parseFloat(headers['x-ratelimit-remaining'] as string);
62+
63+
if (!isNaN(newRate) && newRate > 0) {
64+
this.replenishRate = newRate;
65+
}
66+
67+
if (!isNaN(newBurst) && newBurst > 0) {
68+
this.burstCapacity = newBurst;
69+
}
70+
71+
if (!isNaN(remaining)) {
72+
this.tokens = Math.min(remaining, this.burstCapacity);
73+
}
74+
75+
this.lastRefill = Date.now();
76+
}
77+
}

0 commit comments

Comments
 (0)