Skip to content
This repository was archived by the owner on Jan 19, 2026. It is now read-only.

Commit aa20e80

Browse files
committed
feat: local management api client
1 parent 178a34d commit aa20e80

2 files changed

Lines changed: 271 additions & 88 deletions

File tree

src/api.test.ts

Lines changed: 133 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,148 @@
1-
import { apiClient } from './api';
2-
3-
// Mock the StoryblokClient to prevent actual HTTP requests
4-
vi.mock('storyblok-js-client', () => {
5-
const StoryblokClientMock = vi.fn().mockImplementation((config) => {
6-
return {
7-
config,
8-
};
9-
});
1+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2+
import { createManagementClient, type StoryblokManagementClientOptions } from './api';
3+
import { FetchError } from './utils/fetch';
4+
import { http, HttpResponse } from 'msw';
5+
import { setupServer } from 'msw/node';
106

11-
return {
12-
default: StoryblokClientMock,
13-
__esModule: true, // Important for ESM modules
14-
};
15-
});
7+
// Setup MSW server
8+
const server = setupServer();
169

17-
// Mocking the session module
18-
vi.mock('./session', () => {
19-
let _cache: Record<string, any> | null = null;
20-
const session = () => {
21-
if (!_cache) {
22-
_cache = {
23-
state: {
24-
isLoggedIn: true,
25-
password: 'test-token',
26-
region: 'eu',
27-
},
28-
updateSession: vi.fn(),
29-
persistCredentials: vi.fn(),
30-
initializeSession: vi.fn(),
31-
};
32-
}
33-
return _cache;
34-
};
10+
// Mock response data
11+
const mockResponse = {
12+
data: { id: 1, name: 'Test' },
13+
meta: { status: 200 },
14+
};
3515

36-
return {
37-
session,
16+
describe('storyblok Management Client', () => {
17+
const mockOptions: StoryblokManagementClientOptions = {
18+
accessToken: 'test-token',
19+
region: 'eu',
3820
};
39-
});
4021

41-
describe('storyblok API Client', () => {
42-
beforeEach(async () => {
43-
// Reset the module state before each test to ensure test isolation
44-
vi.resetModules();
45-
vi.clearAllMocks();
22+
// Start server before all tests
23+
beforeAll(() => server.listen());
24+
// Reset handlers and client instance after each test
25+
afterEach(() => {
26+
server.resetHandlers();
4627
});
28+
// Close server after all tests
29+
afterAll(() => server.close());
4730

48-
it('should have a default region of "eu"', () => {
49-
const { region } = apiClient();
50-
expect(region).toBe('eu');
31+
describe('initialization', () => {
32+
beforeEach(() => {
33+
// Note: This is a workaround to reset the client instance for the tests
34+
createManagementClient({
35+
accessToken: 'test-token',
36+
region: 'eu',
37+
}).reset();
38+
});
39+
40+
it('should create a new client instance with valid options', () => {
41+
const client = createManagementClient(mockOptions);
42+
expect(client.getClientName()).toBe('management-client');
43+
expect(client.endpoint).toBeDefined();
44+
});
45+
46+
it('should throw error when trying to get instance without initialization', () => {
47+
expect(() => createManagementClient()).toThrow('MAPI Client requires an access token for initialization');
48+
});
49+
50+
it('should maintain singleton instance', () => {
51+
const client1 = createManagementClient(mockOptions);
52+
const client2 = createManagementClient();
53+
expect(client1).toBe(client2);
54+
});
5155
});
5256

53-
it('should return the same client instance when called multiple times without changes', () => {
54-
const api1 = apiClient();
55-
const client1 = api1.client;
57+
describe('gET requests', () => {
58+
it('should make successful GET request', async () => {
59+
server.use(
60+
http.get('*/test', () => {
61+
return HttpResponse.json(mockResponse);
62+
}),
63+
);
64+
65+
const client = createManagementClient(mockOptions);
66+
const result = await client.get('/test');
5667

57-
const api2 = apiClient();
58-
const client2 = api2.client;
68+
expect(result).toEqual(mockResponse);
69+
});
5970

60-
expect(client1).toBe(client2);
71+
it('should handle non-JSON responses', async () => {
72+
server.use(
73+
http.get('*/test', () => {
74+
return new HttpResponse('Not JSON', {
75+
headers: {
76+
'Content-Type': 'text/plain',
77+
},
78+
});
79+
}),
80+
);
81+
82+
const client = createManagementClient(mockOptions);
83+
await expect(client.get('/test')).rejects.toThrow(FetchError);
84+
});
85+
86+
it('should handle HTTP errors', async () => {
87+
server.use(
88+
http.get('*/test', () => {
89+
return new HttpResponse(null, {
90+
status: 404,
91+
statusText: 'Not Found',
92+
});
93+
}),
94+
);
95+
96+
const client = createManagementClient(mockOptions);
97+
await expect(client.get('/test')).rejects.toThrow(FetchError);
98+
});
99+
100+
it('should handle network errors', async () => {
101+
server.use(
102+
http.get('*/test', () => {
103+
return HttpResponse.error();
104+
}),
105+
);
106+
107+
const client = createManagementClient(mockOptions);
108+
await expect(client.get('/test')).rejects.toThrow(FetchError);
109+
});
61110
});
62111

63-
it('should set the region on the client', () => {
64-
const { setRegion } = apiClient();
65-
setRegion('us');
66-
const { region } = apiClient();
67-
expect(region).toBe('us');
112+
describe('pOST requests', () => {
113+
it('should make successful POST request', async () => {
114+
server.use(
115+
http.post('*/test', async ({ request }) => {
116+
const body = await request.json();
117+
return HttpResponse.json({
118+
...mockResponse,
119+
requestBody: body,
120+
});
121+
}),
122+
);
123+
124+
const client = createManagementClient(mockOptions);
125+
const body = { name: 'Test' };
126+
const result = await client.post('/test', body);
127+
128+
expect(result).toEqual({
129+
...mockResponse,
130+
requestBody: body,
131+
});
132+
});
133+
134+
it('should handle HTTP errors in POST requests', async () => {
135+
server.use(
136+
http.post('*/test', () => {
137+
return new HttpResponse(null, {
138+
status: 400,
139+
statusText: 'Bad Request',
140+
});
141+
}),
142+
);
143+
144+
const client = createManagementClient(mockOptions);
145+
await expect(client.post('/test', {})).rejects.toThrow('API request failed');
146+
});
68147
});
69148
});

src/api.ts

Lines changed: 138 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,155 @@
1-
import StoryblokClient from 'storyblok-js-client';
2-
import { session } from './session';
31
import type { RegionCode } from './constants';
2+
import { getStoryblokUrl } from './utils/api-routes';
3+
import { FetchError } from './utils/fetch';
44

5-
export interface ApiClientState {
5+
/**
6+
* Configuration options for the mapi Client
7+
*/
8+
export interface StoryblokManagementClientOptions {
9+
/**
10+
* Oauth access token for authentication
11+
*/
12+
accessToken: string;
13+
/**
14+
* Region code for the mapi Client
15+
*/
616
region: RegionCode;
17+
}
18+
19+
/**
20+
* Internal state type for the mapi Client
21+
*/
22+
interface StoryblokManagementClientState {
723
accessToken: string;
8-
client: StoryblokClient | null;
24+
headers: Headers;
25+
endpoint?: string;
926
}
1027

11-
const state: ApiClientState = {
12-
region: 'eu',
13-
accessToken: '',
14-
client: null,
15-
};
28+
/**
29+
* Type definition for the mapi Client instance
30+
*/
31+
export interface StoryblokManagementClient {
32+
endpoint?: string;
33+
getClientName: () => string;
34+
/**
35+
* Fetches data from the API
36+
* @param url - The URL path to fetch from
37+
* @returns Promise with the JSON response wrapped in StoryblokBaseResponse
38+
*/
39+
get: <T = unknown>(url: string) => Promise<T>;
40+
post: <T = unknown>(url: string, body: any) => Promise<T>;
41+
reset: () => void;
42+
}
43+
44+
/**
45+
* Creates a singleton instance of the mapi Client
46+
* Using a closure to maintain private state
47+
*/
48+
export const createManagementClient = (() => {
49+
let instance: StoryblokManagementClient | null = null;
50+
51+
const state: StoryblokManagementClientState = {
52+
accessToken: '',
53+
headers: new Headers(),
54+
};
55+
56+
async function get<T = unknown>(url: string): Promise<T> {
57+
if (!state.endpoint) {
58+
throw new Error('mapi Client endpoint is not initialized');
59+
}
60+
61+
try {
62+
// Add token as query parameter
63+
const separator = url.includes('?') ? '&' : '?';
64+
const urlWithToken = `${state.endpoint}${url}${separator}`;
65+
66+
const response = await fetch(urlWithToken, {
67+
headers: state.headers,
68+
});
69+
70+
let data;
71+
try {
72+
data = await response.json();
73+
}
74+
catch {
75+
throw new FetchError('Non-JSON response', {
76+
status: response.status,
77+
statusText: response.statusText,
78+
data: null,
79+
});
80+
}
81+
82+
if (!response.ok) {
83+
throw new FetchError(`HTTP error! status: ${response.status}`, {
84+
status: response.status,
85+
statusText: response.statusText,
86+
data,
87+
});
88+
}
1689

17-
export function apiClient() {
18-
if (!state.client) {
19-
createClient();
90+
return data as T;
91+
}
92+
catch (error) {
93+
if (error instanceof FetchError) {
94+
throw error;
95+
}
96+
// For network errors or other non-HTTP errors
97+
throw new FetchError(error instanceof Error ? error.message : String(error), {
98+
status: 0,
99+
statusText: 'Network Error',
100+
data: null,
101+
});
102+
}
20103
}
21104

22-
function createClient() {
23-
const userSession = session();
24-
if (!userSession.state.isLoggedIn) {
25-
throw new Error('User is not logged in');
105+
async function post<T = unknown>(url: string, body: any): Promise<T> {
106+
if (!state.endpoint) {
107+
throw new Error('mapi Client endpoint is not initialized');
26108
}
27-
state.client = new StoryblokClient({
28-
accessToken: userSession.state.password!,
29-
region: userSession.state.region!,
109+
const requestUrl = `${state.endpoint}/${url}`;
110+
const response = await fetch(requestUrl, {
111+
method: 'POST',
112+
headers: state.headers,
113+
body: JSON.stringify(body),
30114
});
31-
}
32115

33-
function setAccessToken(accessToken: string) {
34-
state.accessToken = accessToken;
35-
state.client = null;
36-
createClient();
116+
if (!response.ok) {
117+
throw new Error(`API request failed: ${response.statusText}`);
118+
}
119+
120+
return response.json() as Promise<T>;
37121
}
38122

39-
function setRegion(region: RegionCode) {
40-
state.region = region;
41-
state.client = null;
42-
createClient();
123+
function reset() {
124+
instance = null;
43125
}
44126

45-
return {
46-
region: state.region,
47-
client: state.client,
48-
setAccessToken,
49-
setRegion,
127+
const initialize = (clientOptions: StoryblokManagementClientOptions): StoryblokManagementClient => {
128+
state.accessToken = clientOptions.accessToken;
129+
state.endpoint = getStoryblokUrl(clientOptions.region);
130+
state.headers.set('Content-Type', 'application/json');
131+
state.headers.set('Accept', 'application/json');
132+
state.headers.set('Authorization', `${clientOptions.accessToken}`);
133+
134+
return {
135+
endpoint: state.endpoint,
136+
getClientName: () => 'management-client',
137+
get,
138+
post,
139+
reset,
140+
};
50141
};
51-
}
142+
143+
return (clientOptions?: StoryblokManagementClientOptions): StoryblokManagementClient => {
144+
if (clientOptions) {
145+
instance = initialize(clientOptions);
146+
}
147+
else if (!instance) {
148+
throw new Error('MAPI Client requires an access token for initialization');
149+
}
150+
return instance;
151+
};
152+
})();
153+
154+
// Export a default function to get the singleton instance
155+
export const managementClient = createManagementClient;

0 commit comments

Comments
 (0)