@startsimpli/billing 0.4.8 → 0.4.9
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/package.json +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/proxy.test.ts +93 -0
- package/src/server/proxy.ts +23 -2
package/package.json
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { camelToSnake, proxyBillingRequest, fetchDjangoServiceToken } from './proxy';
|
|
1
|
+
export { camelToSnake, snakeToCamel, proxyBillingRequest, fetchDjangoServiceToken } from './proxy';
|
|
2
2
|
export type { BillingProxyOptions, DjangoServiceTokenOptions } from './proxy';
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { snakeToCamel, camelToSnake, proxyBillingRequest } from './proxy';
|
|
3
|
+
|
|
4
|
+
describe('snakeToCamel', () => {
|
|
5
|
+
it('converts snake_case keys to camelCase recursively', () => {
|
|
6
|
+
const input = {
|
|
7
|
+
unit_price: '9.00',
|
|
8
|
+
is_free: false,
|
|
9
|
+
nested_obj: { interval_display: 'per month', is_recurring: true },
|
|
10
|
+
list_of_things: [{ cta_text: 'Get Started' }],
|
|
11
|
+
};
|
|
12
|
+
expect(snakeToCamel(input)).toEqual({
|
|
13
|
+
unitPrice: '9.00',
|
|
14
|
+
isFree: false,
|
|
15
|
+
nestedObj: { intervalDisplay: 'per month', isRecurring: true },
|
|
16
|
+
listOfThings: [{ ctaText: 'Get Started' }],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('leaves string values untouched even when they contain underscores', () => {
|
|
21
|
+
expect(snakeToCamel({ feature_key: 'patterns_per_month' })).toEqual({
|
|
22
|
+
featureKey: 'patterns_per_month',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('is idempotent for camelCase keys', () => {
|
|
27
|
+
const input = { alreadyCamel: true, nested: { stillCamel: 1 } };
|
|
28
|
+
expect(snakeToCamel(input)).toEqual(input);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is the inverse of camelToSnake', () => {
|
|
32
|
+
const camel = { unitPrice: '9.00', isFree: true, nested: { ctaText: 'Go' } };
|
|
33
|
+
expect(snakeToCamel(camelToSnake(camel))).toEqual(camel);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('proxyBillingRequest with fromSnake option', () => {
|
|
38
|
+
const originalFetch = globalThis.fetch;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
globalThis.fetch = vi.fn();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
globalThis.fetch = originalFetch;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('transforms snake_case response to camelCase when fromSnake is true', async () => {
|
|
49
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
50
|
+
new Response(
|
|
51
|
+
JSON.stringify({ unit_price: '9.00', is_free: false, offers: [{ cta_text: 'Go' }] }),
|
|
52
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const response = await proxyBillingRequest(
|
|
57
|
+
'http://django',
|
|
58
|
+
'/api/v1/billing/products/x/',
|
|
59
|
+
undefined,
|
|
60
|
+
{ public: true, fromSnake: true }
|
|
61
|
+
);
|
|
62
|
+
const body = await response.json();
|
|
63
|
+
expect(body).toEqual({
|
|
64
|
+
unitPrice: '9.00',
|
|
65
|
+
isFree: false,
|
|
66
|
+
offers: [{ ctaText: 'Go' }],
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('leaves response untouched when no transform option is set', async () => {
|
|
71
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
72
|
+
new Response(JSON.stringify({ alreadyCamel: true }), { status: 200 })
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const response = await proxyBillingRequest('http://django', '/path/', 'token');
|
|
76
|
+
const body = await response.json();
|
|
77
|
+
expect(body).toEqual({ alreadyCamel: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('preserves non-OK status and error shape', async () => {
|
|
81
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
82
|
+
new Response('Not found', { status: 404 })
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const response = await proxyBillingRequest(
|
|
86
|
+
'http://django',
|
|
87
|
+
'/x/',
|
|
88
|
+
undefined,
|
|
89
|
+
{ public: true, fromSnake: true }
|
|
90
|
+
);
|
|
91
|
+
expect(response.status).toBe(404);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/server/proxy.ts
CHANGED
|
@@ -87,11 +87,31 @@ export function camelToSnake(obj: unknown): unknown {
|
|
|
87
87
|
return obj;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Recursively converts snake_case object keys to camelCase.
|
|
92
|
+
* Used for Django endpoints that bypass the camel-case middleware
|
|
93
|
+
* (e.g. public product endpoints) so consumers always receive camelCase.
|
|
94
|
+
*/
|
|
95
|
+
export function snakeToCamel(obj: unknown): unknown {
|
|
96
|
+
if (Array.isArray(obj)) return obj.map(snakeToCamel);
|
|
97
|
+
if (obj !== null && typeof obj === 'object') {
|
|
98
|
+
return Object.fromEntries(
|
|
99
|
+
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
|
|
100
|
+
k.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()),
|
|
101
|
+
snakeToCamel(v),
|
|
102
|
+
])
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return obj;
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
export interface BillingProxyOptions {
|
|
91
109
|
method?: string;
|
|
92
110
|
body?: unknown;
|
|
93
111
|
/** If true, apply camelToSnake transform to the response body */
|
|
94
112
|
normalize?: boolean;
|
|
113
|
+
/** If true, apply snakeToCamel transform to the response body */
|
|
114
|
+
fromSnake?: boolean;
|
|
95
115
|
/** If true, skip Authorization header even when accessToken is provided */
|
|
96
116
|
public?: boolean;
|
|
97
117
|
}
|
|
@@ -110,7 +130,7 @@ export async function proxyBillingRequest(
|
|
|
110
130
|
accessToken: string | undefined,
|
|
111
131
|
options: BillingProxyOptions = {}
|
|
112
132
|
): Promise<NextResponse> {
|
|
113
|
-
const { method = 'GET', body, normalize = false } = options;
|
|
133
|
+
const { method = 'GET', body, normalize = false, fromSnake = false } = options;
|
|
114
134
|
const url = `${apiBaseUrl}${path}`;
|
|
115
135
|
|
|
116
136
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
@@ -135,7 +155,8 @@ export async function proxyBillingRequest(
|
|
|
135
155
|
}
|
|
136
156
|
|
|
137
157
|
const data = await response.json();
|
|
138
|
-
|
|
158
|
+
const transformed = normalize ? camelToSnake(data) : fromSnake ? snakeToCamel(data) : data;
|
|
159
|
+
return NextResponse.json(transformed);
|
|
139
160
|
} catch (error) {
|
|
140
161
|
return NextResponse.json(
|
|
141
162
|
{
|