@onebun/core 0.1.11 → 0.1.13
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
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type as arktype } from 'arktype';
|
|
1
2
|
import {
|
|
2
3
|
describe,
|
|
3
4
|
test,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
Param,
|
|
20
21
|
Query,
|
|
21
22
|
Body,
|
|
23
|
+
Header,
|
|
22
24
|
} from '../decorators/decorators';
|
|
23
25
|
import { Controller as BaseController } from '../module/controller';
|
|
24
26
|
import { makeMockLoggerLayer } from '../testing/test-utils';
|
|
@@ -164,6 +166,98 @@ describe('OneBunApplication', () => {
|
|
|
164
166
|
// The actual value access might need the config to be fully initialized
|
|
165
167
|
// which happens during runtime, not during construction
|
|
166
168
|
});
|
|
169
|
+
|
|
170
|
+
test('should provide typed access to config values via getConfig()', () => {
|
|
171
|
+
@Module({})
|
|
172
|
+
class TestModule {}
|
|
173
|
+
|
|
174
|
+
// Mock config that simulates typed IConfig<OneBunAppConfig>
|
|
175
|
+
const mockConfigValues = {
|
|
176
|
+
server: { port: 9991, host: 'localhost' },
|
|
177
|
+
};
|
|
178
|
+
const mockConfig = {
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
180
|
+
initialize: mock(async () => {}),
|
|
181
|
+
get: mock((path: string) => {
|
|
182
|
+
const parts = path.split('.');
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
184
|
+
let value: any = mockConfigValues;
|
|
185
|
+
for (const part of parts) {
|
|
186
|
+
value = value?.[part];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return value;
|
|
190
|
+
}),
|
|
191
|
+
values: mockConfigValues,
|
|
192
|
+
getSafeConfig: mock(() => mockConfigValues),
|
|
193
|
+
isInitialized: true,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const app = createTestApp(TestModule);
|
|
197
|
+
// Inject mock config
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
199
|
+
(app as any).config = mockConfig;
|
|
200
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
201
|
+
(app as any).configService = {
|
|
202
|
+
get: mockConfig.get, values: mockConfig.values, getSafeConfig: mockConfig.getSafeConfig, isInitialized: true,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// getConfig() returns IConfig<OneBunAppConfig> which provides typed .get() method
|
|
206
|
+
const config = app.getConfig();
|
|
207
|
+
expect(config).toBeDefined();
|
|
208
|
+
|
|
209
|
+
// Access values through the typed interface
|
|
210
|
+
// TypeScript will infer the correct types based on module augmentation
|
|
211
|
+
const port = config.get('server.port');
|
|
212
|
+
const host = config.get('server.host');
|
|
213
|
+
|
|
214
|
+
expect(port).toBe(9991);
|
|
215
|
+
expect(host).toBe('localhost');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('should provide typed access via getConfigValue() convenience method', () => {
|
|
219
|
+
@Module({})
|
|
220
|
+
class TestModule {}
|
|
221
|
+
|
|
222
|
+
// Mock config that simulates typed IConfig<OneBunAppConfig>
|
|
223
|
+
const mockConfigValues = {
|
|
224
|
+
app: { name: 'test-app', debug: true },
|
|
225
|
+
};
|
|
226
|
+
const mockConfig = {
|
|
227
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
228
|
+
initialize: mock(async () => {}),
|
|
229
|
+
get: mock((path: string) => {
|
|
230
|
+
const parts = path.split('.');
|
|
231
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
232
|
+
let value: any = mockConfigValues;
|
|
233
|
+
for (const part of parts) {
|
|
234
|
+
value = value?.[part];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return value;
|
|
238
|
+
}),
|
|
239
|
+
values: mockConfigValues,
|
|
240
|
+
getSafeConfig: mock(() => mockConfigValues),
|
|
241
|
+
isInitialized: true,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const app = createTestApp(TestModule);
|
|
245
|
+
// Inject mock config
|
|
246
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
247
|
+
(app as any).config = mockConfig;
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
249
|
+
(app as any).configService = {
|
|
250
|
+
get: mockConfig.get, values: mockConfig.values, getSafeConfig: mockConfig.getSafeConfig, isInitialized: true,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// getConfigValue() is a convenience method that delegates to getConfig().get()
|
|
254
|
+
// It also provides typed access based on OneBunAppConfig module augmentation
|
|
255
|
+
const appName = app.getConfigValue('app.name');
|
|
256
|
+
const debug = app.getConfigValue('app.debug');
|
|
257
|
+
|
|
258
|
+
expect(appName).toBe('test-app');
|
|
259
|
+
expect(debug).toBe(true);
|
|
260
|
+
});
|
|
167
261
|
});
|
|
168
262
|
|
|
169
263
|
describe('Layer methods', () => {
|
|
@@ -855,10 +949,722 @@ describe('OneBunApplication', () => {
|
|
|
855
949
|
|
|
856
950
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
857
951
|
const response = await (mockServer as any).fetchHandler(request);
|
|
952
|
+
const body = await response.json();
|
|
858
953
|
|
|
859
|
-
expect(response).
|
|
860
|
-
|
|
861
|
-
expect(
|
|
954
|
+
expect(response.status).toBe(200);
|
|
955
|
+
expect(body.result.query).toBe('test');
|
|
956
|
+
expect(body.result.limit).toBe(5);
|
|
957
|
+
expect(body.result.results).toEqual(['item1', 'item2']);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test('should handle URL-encoded query parameters', async () => {
|
|
961
|
+
@Controller('/api')
|
|
962
|
+
class ApiController extends BaseController {
|
|
963
|
+
@Get('/search')
|
|
964
|
+
async search(
|
|
965
|
+
@Query('name') name: string,
|
|
966
|
+
@Query('filter') filter?: string,
|
|
967
|
+
) {
|
|
968
|
+
return { name, filter };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
@Module({
|
|
973
|
+
controllers: [ApiController],
|
|
974
|
+
})
|
|
975
|
+
class TestModule {}
|
|
976
|
+
|
|
977
|
+
const app = createTestApp(TestModule);
|
|
978
|
+
await app.start();
|
|
979
|
+
|
|
980
|
+
// Test URL-encoded values: "John Doe" and "test&value"
|
|
981
|
+
const request = new Request('http://localhost:3000/api/search?name=John%20Doe&filter=test%26value', {
|
|
982
|
+
method: 'GET',
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
986
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
987
|
+
const body = await response.json();
|
|
988
|
+
|
|
989
|
+
expect(response.status).toBe(200);
|
|
990
|
+
expect(body.result.name).toBe('John Doe');
|
|
991
|
+
expect(body.result.filter).toBe('test&value');
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test('should handle OAuth callback query string with special characters', async () => {
|
|
995
|
+
@Controller('/api/auth/google')
|
|
996
|
+
class AuthController extends BaseController {
|
|
997
|
+
@Get('/callback')
|
|
998
|
+
async callback(
|
|
999
|
+
@Query('state') state: string,
|
|
1000
|
+
@Query('code') code: string,
|
|
1001
|
+
@Query('scope') scope: string,
|
|
1002
|
+
@Query('authuser') authuser?: string,
|
|
1003
|
+
@Query('prompt') prompt?: string,
|
|
1004
|
+
) {
|
|
1005
|
+
return {
|
|
1006
|
+
state, code, scope, authuser, prompt,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
@Module({
|
|
1012
|
+
controllers: [AuthController],
|
|
1013
|
+
})
|
|
1014
|
+
class TestModule {}
|
|
1015
|
+
|
|
1016
|
+
const app = createTestApp(TestModule);
|
|
1017
|
+
await app.start();
|
|
1018
|
+
|
|
1019
|
+
// Real OAuth callback URL from the user's example
|
|
1020
|
+
const queryString = 'state=b6d290537858f64d894a47480c5e3edd&code=4/0ASc3gC0o5UhWEjUTslteiiSpR6_NsLYXXdfCjDq0rPFYymqB7LMofianDqC1l4NHJXvA3A&scope=email%20profile%20https://www.googleapis.com/auth/userinfo.profile%20https://www.googleapis.com/auth/userinfo.email%20openid&authuser=0&prompt=consent';
|
|
1021
|
+
const request = new Request(`http://localhost:3000/api/auth/google/callback?${queryString}`, {
|
|
1022
|
+
method: 'GET',
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1026
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1027
|
+
const body = await response.json();
|
|
1028
|
+
|
|
1029
|
+
expect(response.status).toBe(200);
|
|
1030
|
+
expect(body.result.state).toBe('b6d290537858f64d894a47480c5e3edd');
|
|
1031
|
+
expect(body.result.code).toBe('4/0ASc3gC0o5UhWEjUTslteiiSpR6_NsLYXXdfCjDq0rPFYymqB7LMofianDqC1l4NHJXvA3A');
|
|
1032
|
+
expect(body.result.scope).toBe('email profile https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid');
|
|
1033
|
+
expect(body.result.authuser).toBe('0');
|
|
1034
|
+
expect(body.result.prompt).toBe('consent');
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test('should handle multiple query parameters with same key as array', async () => {
|
|
1038
|
+
@Controller('/api')
|
|
1039
|
+
class ApiController extends BaseController {
|
|
1040
|
+
@Get('/filter')
|
|
1041
|
+
async filter(@Query('tag') tag: string | string[]) {
|
|
1042
|
+
return { tags: Array.isArray(tag) ? tag : [tag] };
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
@Module({
|
|
1047
|
+
controllers: [ApiController],
|
|
1048
|
+
})
|
|
1049
|
+
class TestModule {}
|
|
1050
|
+
|
|
1051
|
+
const app = createTestApp(TestModule);
|
|
1052
|
+
await app.start();
|
|
1053
|
+
|
|
1054
|
+
// Multiple values with same key: ?tag=a&tag=b&tag=c
|
|
1055
|
+
const request = new Request('http://localhost:3000/api/filter?tag=a&tag=b&tag=c', {
|
|
1056
|
+
method: 'GET',
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1060
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1061
|
+
const body = await response.json();
|
|
1062
|
+
|
|
1063
|
+
expect(response.status).toBe(200);
|
|
1064
|
+
expect(body.result.tags).toEqual(['a', 'b', 'c']);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test('should handle array notation query parameters (tag[]=a&tag[]=b)', async () => {
|
|
1068
|
+
@Controller('/api')
|
|
1069
|
+
class ApiController extends BaseController {
|
|
1070
|
+
@Get('/filter')
|
|
1071
|
+
async filter(@Query('tag') tag: string[]) {
|
|
1072
|
+
return { tags: tag };
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
@Module({
|
|
1077
|
+
controllers: [ApiController],
|
|
1078
|
+
})
|
|
1079
|
+
class TestModule {}
|
|
1080
|
+
|
|
1081
|
+
const app = createTestApp(TestModule);
|
|
1082
|
+
await app.start();
|
|
1083
|
+
|
|
1084
|
+
// Array notation: ?tag[]=a&tag[]=b&tag[]=c
|
|
1085
|
+
const request = new Request('http://localhost:3000/api/filter?tag[]=a&tag[]=b&tag[]=c', {
|
|
1086
|
+
method: 'GET',
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1090
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1091
|
+
const body = await response.json();
|
|
1092
|
+
|
|
1093
|
+
expect(response.status).toBe(200);
|
|
1094
|
+
expect(body.result.tags).toEqual(['a', 'b', 'c']);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test('should handle single value with array notation (tag[]=a)', async () => {
|
|
1098
|
+
@Controller('/api')
|
|
1099
|
+
class ApiController extends BaseController {
|
|
1100
|
+
@Get('/filter')
|
|
1101
|
+
async filter(@Query('tag') tag: string[]) {
|
|
1102
|
+
return { tags: tag, isArray: Array.isArray(tag) };
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
@Module({
|
|
1107
|
+
controllers: [ApiController],
|
|
1108
|
+
})
|
|
1109
|
+
class TestModule {}
|
|
1110
|
+
|
|
1111
|
+
const app = createTestApp(TestModule);
|
|
1112
|
+
await app.start();
|
|
1113
|
+
|
|
1114
|
+
// Single value with array notation should still be an array
|
|
1115
|
+
const request = new Request('http://localhost:3000/api/filter?tag[]=single', {
|
|
1116
|
+
method: 'GET',
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1120
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1121
|
+
const body = await response.json();
|
|
1122
|
+
|
|
1123
|
+
expect(response.status).toBe(200);
|
|
1124
|
+
expect(body.result.tags).toEqual(['single']);
|
|
1125
|
+
expect(body.result.isArray).toBe(true);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
test('should handle empty query parameter values', async () => {
|
|
1129
|
+
@Controller('/api')
|
|
1130
|
+
class ApiController extends BaseController {
|
|
1131
|
+
@Get('/params')
|
|
1132
|
+
async params(
|
|
1133
|
+
@Query('empty') empty: string,
|
|
1134
|
+
@Query('other') other: string,
|
|
1135
|
+
) {
|
|
1136
|
+
return { empty, other, emptyIsString: typeof empty === 'string' };
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
@Module({
|
|
1141
|
+
controllers: [ApiController],
|
|
1142
|
+
})
|
|
1143
|
+
class TestModule {}
|
|
1144
|
+
|
|
1145
|
+
const app = createTestApp(TestModule);
|
|
1146
|
+
await app.start();
|
|
1147
|
+
|
|
1148
|
+
// Empty value: ?empty=&other=value
|
|
1149
|
+
const request = new Request('http://localhost:3000/api/params?empty=&other=value', {
|
|
1150
|
+
method: 'GET',
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1154
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1155
|
+
const body = await response.json();
|
|
1156
|
+
|
|
1157
|
+
expect(response.status).toBe(200);
|
|
1158
|
+
expect(body.result.empty).toBe('');
|
|
1159
|
+
expect(body.result.other).toBe('value');
|
|
1160
|
+
expect(body.result.emptyIsString).toBe(true);
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
test('should handle missing optional query parameters', async () => {
|
|
1164
|
+
@Controller('/api')
|
|
1165
|
+
class ApiController extends BaseController {
|
|
1166
|
+
@Get('/optional')
|
|
1167
|
+
async optional(
|
|
1168
|
+
@Query('required') required: string,
|
|
1169
|
+
@Query('optional') optional?: string,
|
|
1170
|
+
) {
|
|
1171
|
+
return { required, optional, hasOptional: optional !== undefined };
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
@Module({
|
|
1176
|
+
controllers: [ApiController],
|
|
1177
|
+
})
|
|
1178
|
+
class TestModule {}
|
|
1179
|
+
|
|
1180
|
+
const app = createTestApp(TestModule);
|
|
1181
|
+
await app.start();
|
|
1182
|
+
|
|
1183
|
+
// Only required parameter, optional is missing
|
|
1184
|
+
const request = new Request('http://localhost:3000/api/optional?required=value', {
|
|
1185
|
+
method: 'GET',
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1189
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1190
|
+
const body = await response.json();
|
|
1191
|
+
|
|
1192
|
+
expect(response.status).toBe(200);
|
|
1193
|
+
expect(body.result.required).toBe('value');
|
|
1194
|
+
expect(body.result.optional).toBeUndefined();
|
|
1195
|
+
expect(body.result.hasOptional).toBe(false);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
test('should handle multiple path parameters', async () => {
|
|
1199
|
+
@Controller('/api')
|
|
1200
|
+
class ApiController extends BaseController {
|
|
1201
|
+
@Get('/users/:userId/posts/:postId')
|
|
1202
|
+
async getPost(
|
|
1203
|
+
@Param('userId') userId: string,
|
|
1204
|
+
@Param('postId') postId: string,
|
|
1205
|
+
) {
|
|
1206
|
+
return { userId: parseInt(userId), postId: parseInt(postId) };
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
@Module({
|
|
1211
|
+
controllers: [ApiController],
|
|
1212
|
+
})
|
|
1213
|
+
class TestModule {}
|
|
1214
|
+
|
|
1215
|
+
const app = createTestApp(TestModule);
|
|
1216
|
+
await app.start();
|
|
1217
|
+
|
|
1218
|
+
const request = new Request('http://localhost:3000/api/users/42/posts/123', {
|
|
1219
|
+
method: 'GET',
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1223
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1224
|
+
const body = await response.json();
|
|
1225
|
+
|
|
1226
|
+
expect(response.status).toBe(200);
|
|
1227
|
+
expect(body.result.userId).toBe(42);
|
|
1228
|
+
expect(body.result.postId).toBe(123);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
test('should handle URL-encoded path parameters', async () => {
|
|
1232
|
+
@Controller('/api')
|
|
1233
|
+
class ApiController extends BaseController {
|
|
1234
|
+
@Get('/files/:filename')
|
|
1235
|
+
async getFile(@Param('filename') filename: string) {
|
|
1236
|
+
return { filename };
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
@Module({
|
|
1241
|
+
controllers: [ApiController],
|
|
1242
|
+
})
|
|
1243
|
+
class TestModule {}
|
|
1244
|
+
|
|
1245
|
+
const app = createTestApp(TestModule);
|
|
1246
|
+
await app.start();
|
|
1247
|
+
|
|
1248
|
+
// URL-encoded filename: "my file.txt"
|
|
1249
|
+
const request = new Request('http://localhost:3000/api/files/my%20file.txt', {
|
|
1250
|
+
method: 'GET',
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1254
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1255
|
+
const body = await response.json();
|
|
1256
|
+
|
|
1257
|
+
expect(response.status).toBe(200);
|
|
1258
|
+
expect(body.result.filename).toBe('my%20file.txt');
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
test('should handle path parameters with query parameters together', async () => {
|
|
1262
|
+
@Controller('/api')
|
|
1263
|
+
class ApiController extends BaseController {
|
|
1264
|
+
@Get('/users/:id/posts')
|
|
1265
|
+
async getUserPosts(
|
|
1266
|
+
@Param('id') userId: string,
|
|
1267
|
+
@Query('page') page?: string,
|
|
1268
|
+
@Query('limit') limit?: string,
|
|
1269
|
+
) {
|
|
1270
|
+
return {
|
|
1271
|
+
userId: parseInt(userId),
|
|
1272
|
+
page: page ? parseInt(page) : 1,
|
|
1273
|
+
limit: limit ? parseInt(limit) : 10,
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
@Module({
|
|
1279
|
+
controllers: [ApiController],
|
|
1280
|
+
})
|
|
1281
|
+
class TestModule {}
|
|
1282
|
+
|
|
1283
|
+
const app = createTestApp(TestModule);
|
|
1284
|
+
await app.start();
|
|
1285
|
+
|
|
1286
|
+
const request = new Request('http://localhost:3000/api/users/5/posts?page=2&limit=20', {
|
|
1287
|
+
method: 'GET',
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1291
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1292
|
+
const body = await response.json();
|
|
1293
|
+
|
|
1294
|
+
expect(response.status).toBe(200);
|
|
1295
|
+
expect(body.result.userId).toBe(5);
|
|
1296
|
+
expect(body.result.page).toBe(2);
|
|
1297
|
+
expect(body.result.limit).toBe(20);
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
test('should handle nested JSON body', async () => {
|
|
1301
|
+
@Controller('/api')
|
|
1302
|
+
class ApiController extends BaseController {
|
|
1303
|
+
@Post('/complex')
|
|
1304
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1305
|
+
async createComplex(@Body() data: any) {
|
|
1306
|
+
return { received: data };
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
@Module({
|
|
1311
|
+
controllers: [ApiController],
|
|
1312
|
+
})
|
|
1313
|
+
class TestModule {}
|
|
1314
|
+
|
|
1315
|
+
const app = createTestApp(TestModule);
|
|
1316
|
+
await app.start();
|
|
1317
|
+
|
|
1318
|
+
const complexData = {
|
|
1319
|
+
user: {
|
|
1320
|
+
name: 'John',
|
|
1321
|
+
address: {
|
|
1322
|
+
city: 'NYC',
|
|
1323
|
+
zip: '10001',
|
|
1324
|
+
},
|
|
1325
|
+
},
|
|
1326
|
+
items: [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }],
|
|
1327
|
+
metadata: {
|
|
1328
|
+
created: '2024-01-01',
|
|
1329
|
+
tags: ['tag1', 'tag2'],
|
|
1330
|
+
},
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
const request = new Request('http://localhost:3000/api/complex', {
|
|
1334
|
+
method: 'POST',
|
|
1335
|
+
headers: {
|
|
1336
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1337
|
+
'Content-Type': 'application/json',
|
|
1338
|
+
},
|
|
1339
|
+
body: JSON.stringify(complexData),
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1343
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1344
|
+
const body = await response.json();
|
|
1345
|
+
|
|
1346
|
+
expect(response.status).toBe(200);
|
|
1347
|
+
expect(body.result.received).toEqual(complexData);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test('should handle empty body gracefully', async () => {
|
|
1351
|
+
@Controller('/api')
|
|
1352
|
+
class ApiController extends BaseController {
|
|
1353
|
+
@Post('/empty-body')
|
|
1354
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1355
|
+
async handleEmpty(@Body() data?: any) {
|
|
1356
|
+
return { hasBody: data !== undefined, data };
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
@Module({
|
|
1361
|
+
controllers: [ApiController],
|
|
1362
|
+
})
|
|
1363
|
+
class TestModule {}
|
|
1364
|
+
|
|
1365
|
+
const app = createTestApp(TestModule);
|
|
1366
|
+
await app.start();
|
|
1367
|
+
|
|
1368
|
+
const request = new Request('http://localhost:3000/api/empty-body', {
|
|
1369
|
+
method: 'POST',
|
|
1370
|
+
headers: {
|
|
1371
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1372
|
+
'Content-Type': 'application/json',
|
|
1373
|
+
},
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1377
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1378
|
+
const body = await response.json();
|
|
1379
|
+
|
|
1380
|
+
expect(response.status).toBe(200);
|
|
1381
|
+
expect(body.result.hasBody).toBe(false);
|
|
1382
|
+
expect(body.result.data).toBeUndefined();
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
test('should handle header parameters', async () => {
|
|
1386
|
+
@Controller('/api')
|
|
1387
|
+
class ApiController extends BaseController {
|
|
1388
|
+
@Get('/headers')
|
|
1389
|
+
async getHeaders(
|
|
1390
|
+
@Header('Authorization') auth: string,
|
|
1391
|
+
@Header('X-Custom-Header') custom?: string,
|
|
1392
|
+
) {
|
|
1393
|
+
return { auth, custom };
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
@Module({
|
|
1398
|
+
controllers: [ApiController],
|
|
1399
|
+
})
|
|
1400
|
+
class TestModule {}
|
|
1401
|
+
|
|
1402
|
+
const app = createTestApp(TestModule);
|
|
1403
|
+
await app.start();
|
|
1404
|
+
|
|
1405
|
+
const request = new Request('http://localhost:3000/api/headers', {
|
|
1406
|
+
method: 'GET',
|
|
1407
|
+
headers: {
|
|
1408
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1409
|
+
'Authorization': 'Bearer token123',
|
|
1410
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1411
|
+
'X-Custom-Header': 'custom-value',
|
|
1412
|
+
},
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1416
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1417
|
+
const body = await response.json();
|
|
1418
|
+
|
|
1419
|
+
expect(response.status).toBe(200);
|
|
1420
|
+
expect(body.result.auth).toBe('Bearer token123');
|
|
1421
|
+
expect(body.result.custom).toBe('custom-value');
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
test('should handle missing optional header', async () => {
|
|
1425
|
+
@Controller('/api')
|
|
1426
|
+
class ApiController extends BaseController {
|
|
1427
|
+
@Get('/optional-header')
|
|
1428
|
+
async getOptionalHeader(
|
|
1429
|
+
@Header('X-Required') required: string,
|
|
1430
|
+
@Header('X-Optional') optional?: string | null,
|
|
1431
|
+
) {
|
|
1432
|
+
// Note: headers.get() returns null for missing headers, not undefined
|
|
1433
|
+
return { required, optional, hasOptional: optional !== null };
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
@Module({
|
|
1438
|
+
controllers: [ApiController],
|
|
1439
|
+
})
|
|
1440
|
+
class TestModule {}
|
|
1441
|
+
|
|
1442
|
+
const app = createTestApp(TestModule);
|
|
1443
|
+
await app.start();
|
|
1444
|
+
|
|
1445
|
+
const request = new Request('http://localhost:3000/api/optional-header', {
|
|
1446
|
+
method: 'GET',
|
|
1447
|
+
headers: {
|
|
1448
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1449
|
+
'X-Required': 'required-value',
|
|
1450
|
+
},
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1454
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1455
|
+
const body = await response.json();
|
|
1456
|
+
|
|
1457
|
+
expect(response.status).toBe(200);
|
|
1458
|
+
expect(body.result.required).toBe('required-value');
|
|
1459
|
+
// headers.get() returns null for missing headers
|
|
1460
|
+
expect(body.result.optional).toBeNull();
|
|
1461
|
+
expect(body.result.hasOptional).toBe(false);
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
test('should return 500 when required query parameter is missing', async () => {
|
|
1465
|
+
@Controller('/api')
|
|
1466
|
+
class ApiController extends BaseController {
|
|
1467
|
+
@Get('/required-query')
|
|
1468
|
+
async requiredQuery(
|
|
1469
|
+
@Query('required', { required: true }) required: string,
|
|
1470
|
+
) {
|
|
1471
|
+
return { required };
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
@Module({
|
|
1476
|
+
controllers: [ApiController],
|
|
1477
|
+
})
|
|
1478
|
+
class TestModule {}
|
|
1479
|
+
|
|
1480
|
+
const app = createTestApp(TestModule);
|
|
1481
|
+
await app.start();
|
|
1482
|
+
|
|
1483
|
+
// Missing required query parameter
|
|
1484
|
+
const request = new Request('http://localhost:3000/api/required-query', {
|
|
1485
|
+
method: 'GET',
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1489
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1490
|
+
|
|
1491
|
+
expect(response.status).toBe(500);
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
test('should pass validation with required query parameter present', async () => {
|
|
1495
|
+
@Controller('/api')
|
|
1496
|
+
class ApiController extends BaseController {
|
|
1497
|
+
@Get('/required-query')
|
|
1498
|
+
async requiredQuery(
|
|
1499
|
+
@Query('required', { required: true }) required: string,
|
|
1500
|
+
) {
|
|
1501
|
+
return { required };
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
@Module({
|
|
1506
|
+
controllers: [ApiController],
|
|
1507
|
+
})
|
|
1508
|
+
class TestModule {}
|
|
1509
|
+
|
|
1510
|
+
const app = createTestApp(TestModule);
|
|
1511
|
+
await app.start();
|
|
1512
|
+
|
|
1513
|
+
const request = new Request('http://localhost:3000/api/required-query?required=value', {
|
|
1514
|
+
method: 'GET',
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1518
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1519
|
+
const body = await response.json();
|
|
1520
|
+
|
|
1521
|
+
expect(response.status).toBe(200);
|
|
1522
|
+
expect(body.result.required).toBe('value');
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
test('should validate query parameter with arktype schema', async () => {
|
|
1526
|
+
const numberSchema = arktype('string.numeric.parse');
|
|
1527
|
+
|
|
1528
|
+
@Controller('/api')
|
|
1529
|
+
class ApiController extends BaseController {
|
|
1530
|
+
@Get('/validated')
|
|
1531
|
+
async validated(
|
|
1532
|
+
@Query('count', numberSchema) count: number,
|
|
1533
|
+
) {
|
|
1534
|
+
return { count, typeOf: typeof count };
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
@Module({
|
|
1539
|
+
controllers: [ApiController],
|
|
1540
|
+
})
|
|
1541
|
+
class TestModule {}
|
|
1542
|
+
|
|
1543
|
+
const app = createTestApp(TestModule);
|
|
1544
|
+
await app.start();
|
|
1545
|
+
|
|
1546
|
+
const request = new Request('http://localhost:3000/api/validated?count=42', {
|
|
1547
|
+
method: 'GET',
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1551
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1552
|
+
const body = await response.json();
|
|
1553
|
+
|
|
1554
|
+
expect(response.status).toBe(200);
|
|
1555
|
+
expect(body.result.count).toBe(42);
|
|
1556
|
+
expect(body.result.typeOf).toBe('number');
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
test('should fail validation with invalid arktype schema value', async () => {
|
|
1560
|
+
const numberSchema = arktype('string.numeric.parse');
|
|
1561
|
+
|
|
1562
|
+
@Controller('/api')
|
|
1563
|
+
class ApiController extends BaseController {
|
|
1564
|
+
@Get('/validated')
|
|
1565
|
+
async validated(
|
|
1566
|
+
@Query('count', numberSchema) count: number,
|
|
1567
|
+
) {
|
|
1568
|
+
return { count };
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
@Module({
|
|
1573
|
+
controllers: [ApiController],
|
|
1574
|
+
})
|
|
1575
|
+
class TestModule {}
|
|
1576
|
+
|
|
1577
|
+
const app = createTestApp(TestModule);
|
|
1578
|
+
await app.start();
|
|
1579
|
+
|
|
1580
|
+
// Invalid value: "not-a-number" instead of numeric string
|
|
1581
|
+
const request = new Request('http://localhost:3000/api/validated?count=not-a-number', {
|
|
1582
|
+
method: 'GET',
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1586
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1587
|
+
|
|
1588
|
+
expect(response.status).toBe(500);
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
test('should validate body with arktype schema', async () => {
|
|
1592
|
+
const userSchema = arktype({
|
|
1593
|
+
name: 'string',
|
|
1594
|
+
age: 'number',
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
@Controller('/api')
|
|
1598
|
+
class ApiController extends BaseController {
|
|
1599
|
+
@Post('/user')
|
|
1600
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1601
|
+
async createUser(@Body(userSchema) user: any) {
|
|
1602
|
+
return { user };
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
@Module({
|
|
1607
|
+
controllers: [ApiController],
|
|
1608
|
+
})
|
|
1609
|
+
class TestModule {}
|
|
1610
|
+
|
|
1611
|
+
const app = createTestApp(TestModule);
|
|
1612
|
+
await app.start();
|
|
1613
|
+
|
|
1614
|
+
const request = new Request('http://localhost:3000/api/user', {
|
|
1615
|
+
method: 'POST',
|
|
1616
|
+
headers: {
|
|
1617
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1618
|
+
'Content-Type': 'application/json',
|
|
1619
|
+
},
|
|
1620
|
+
body: JSON.stringify({ name: 'John', age: 30 }),
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1624
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1625
|
+
const body = await response.json();
|
|
1626
|
+
|
|
1627
|
+
expect(response.status).toBe(200);
|
|
1628
|
+
expect(body.result.user).toEqual({ name: 'John', age: 30 });
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
test('should fail body validation with invalid data', async () => {
|
|
1632
|
+
const userSchema = arktype({
|
|
1633
|
+
name: 'string',
|
|
1634
|
+
age: 'number',
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
@Controller('/api')
|
|
1638
|
+
class ApiController extends BaseController {
|
|
1639
|
+
@Post('/user')
|
|
1640
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1641
|
+
async createUser(@Body(userSchema) user: any) {
|
|
1642
|
+
return { user };
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
@Module({
|
|
1647
|
+
controllers: [ApiController],
|
|
1648
|
+
})
|
|
1649
|
+
class TestModule {}
|
|
1650
|
+
|
|
1651
|
+
const app = createTestApp(TestModule);
|
|
1652
|
+
await app.start();
|
|
1653
|
+
|
|
1654
|
+
// Invalid: age is string instead of number
|
|
1655
|
+
const request = new Request('http://localhost:3000/api/user', {
|
|
1656
|
+
method: 'POST',
|
|
1657
|
+
headers: {
|
|
1658
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
1659
|
+
'Content-Type': 'application/json',
|
|
1660
|
+
},
|
|
1661
|
+
body: JSON.stringify({ name: 'John', age: 'thirty' }),
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1665
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
1666
|
+
|
|
1667
|
+
expect(response.status).toBe(500);
|
|
862
1668
|
});
|
|
863
1669
|
|
|
864
1670
|
test('should handle metrics endpoint', async () => {
|
|
@@ -3,7 +3,11 @@ import { Effect, type Layer } from 'effect';
|
|
|
3
3
|
import type { Controller } from '../module/controller';
|
|
4
4
|
import type { WsClientData } from '../websocket/ws.types';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type DeepPaths,
|
|
8
|
+
type DeepValue,
|
|
9
|
+
TypedEnv,
|
|
10
|
+
} from '@onebun/envs';
|
|
7
11
|
import {
|
|
8
12
|
createSyncLogger,
|
|
9
13
|
type Logger,
|
|
@@ -230,9 +234,21 @@ export class OneBunApplication {
|
|
|
230
234
|
}
|
|
231
235
|
|
|
232
236
|
/**
|
|
233
|
-
* Get configuration service
|
|
237
|
+
* Get configuration service with full type inference.
|
|
238
|
+
* Uses module augmentation of OneBunAppConfig for type-safe access.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* // With module augmentation:
|
|
242
|
+
* declare module '@onebun/core' {
|
|
243
|
+
* interface OneBunAppConfig {
|
|
244
|
+
* server: { port: number; host: string };
|
|
245
|
+
* }
|
|
246
|
+
* }
|
|
247
|
+
*
|
|
248
|
+
* const config = app.getConfig();
|
|
249
|
+
* const port = config.get('server.port'); // number
|
|
234
250
|
*/
|
|
235
|
-
getConfig():
|
|
251
|
+
getConfig(): IConfig<OneBunAppConfig> {
|
|
236
252
|
if (!this.configService) {
|
|
237
253
|
throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
|
|
238
254
|
}
|
|
@@ -241,10 +257,25 @@ export class OneBunApplication {
|
|
|
241
257
|
}
|
|
242
258
|
|
|
243
259
|
/**
|
|
244
|
-
* Get configuration value by path (convenience method)
|
|
260
|
+
* Get configuration value by path (convenience method) with full type inference.
|
|
261
|
+
* Uses module augmentation of OneBunAppConfig for type-safe access.
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* // With module augmentation:
|
|
265
|
+
* declare module '@onebun/core' {
|
|
266
|
+
* interface OneBunAppConfig {
|
|
267
|
+
* server: { port: number; host: string };
|
|
268
|
+
* }
|
|
269
|
+
* }
|
|
270
|
+
*
|
|
271
|
+
* const port = app.getConfigValue('server.port'); // number
|
|
272
|
+
* const host = app.getConfigValue('server.host'); // string
|
|
245
273
|
*/
|
|
246
|
-
getConfigValue<
|
|
247
|
-
|
|
274
|
+
getConfigValue<P extends DeepPaths<OneBunAppConfig>>(path: P): DeepValue<OneBunAppConfig, P>;
|
|
275
|
+
/** Fallback for dynamic paths */
|
|
276
|
+
getConfigValue<T = unknown>(path: string): T;
|
|
277
|
+
getConfigValue(path: string): unknown {
|
|
278
|
+
return this.getConfig().get(path);
|
|
248
279
|
}
|
|
249
280
|
|
|
250
281
|
/**
|
|
@@ -604,6 +635,26 @@ export class OneBunApplication {
|
|
|
604
635
|
let route = routes.get(exactRouteKey);
|
|
605
636
|
const paramValues: Record<string, string | string[]> = {};
|
|
606
637
|
|
|
638
|
+
// Extract query parameters from URL
|
|
639
|
+
for (const [rawKey, value] of url.searchParams.entries()) {
|
|
640
|
+
// Handle array notation: tag[] -> tag (as array)
|
|
641
|
+
const isArrayNotation = rawKey.endsWith('[]');
|
|
642
|
+
const key = isArrayNotation ? rawKey.replace('[]', '') : rawKey;
|
|
643
|
+
|
|
644
|
+
const existing = paramValues[key];
|
|
645
|
+
if (existing !== undefined) {
|
|
646
|
+
// Handle multiple values with same key (e.g., ?tag=a&tag=b or ?tag[]=a&tag[]=b)
|
|
647
|
+
paramValues[key] = Array.isArray(existing)
|
|
648
|
+
? [...existing, value]
|
|
649
|
+
: [existing, value];
|
|
650
|
+
} else if (isArrayNotation) {
|
|
651
|
+
// Array notation always creates an array, even with single value
|
|
652
|
+
paramValues[key] = [value];
|
|
653
|
+
} else {
|
|
654
|
+
paramValues[key] = value;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
607
658
|
// If no exact match, try pattern matching
|
|
608
659
|
if (!route) {
|
|
609
660
|
for (const [_routeKey, routeData] of routes) {
|
|
@@ -141,7 +141,8 @@ describe('ConfigService', () => {
|
|
|
141
141
|
|
|
142
142
|
const result = service.values;
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
// Use unknown cast because tests use mock data that doesn't match OneBunAppConfig augmentation
|
|
145
|
+
expect(result as unknown).toEqual({ test: 'value' });
|
|
145
146
|
});
|
|
146
147
|
|
|
147
148
|
test('should throw error when config not initialized', () => {
|
|
@@ -159,7 +160,8 @@ describe('ConfigService', () => {
|
|
|
159
160
|
|
|
160
161
|
const result = service.getSafeConfig();
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
// Use unknown cast because tests use mock data that doesn't match OneBunAppConfig augmentation
|
|
164
|
+
expect(result as unknown).toEqual({ test: '***' });
|
|
163
165
|
expect(mockConfig.getSafeConfig).toHaveBeenCalled();
|
|
164
166
|
});
|
|
165
167
|
|
|
@@ -344,13 +346,15 @@ describe('ConfigService', () => {
|
|
|
344
346
|
|
|
345
347
|
expect((service as any).get('database.host')).toBe('localhost');
|
|
346
348
|
expect((service as any).get('database.port')).toBe(5432);
|
|
347
|
-
|
|
349
|
+
// Use unknown cast because tests use mock data that doesn't match OneBunAppConfig augmentation
|
|
350
|
+
expect(service.values as unknown).toEqual({
|
|
348
351
|
database: { host: 'localhost', port: 5432 },
|
|
349
352
|
api: { key: 'secret', timeout: 30000 },
|
|
350
353
|
});
|
|
351
354
|
|
|
352
355
|
const safeConfig = service.getSafeConfig();
|
|
353
|
-
|
|
356
|
+
// Use unknown cast because tests use mock data that doesn't match OneBunAppConfig augmentation
|
|
357
|
+
expect(safeConfig as unknown).toEqual({
|
|
354
358
|
database: { host: 'localhost', port: 5432 },
|
|
355
359
|
api: { key: '***', timeout: 30000 },
|
|
356
360
|
});
|
|
@@ -2,6 +2,7 @@ import { Context } from 'effect';
|
|
|
2
2
|
|
|
3
3
|
import type { IConfig, OneBunAppConfig } from './config.interface';
|
|
4
4
|
|
|
5
|
+
import type { DeepPaths, DeepValue } from '@onebun/envs';
|
|
5
6
|
import type { SyncLogger } from '@onebun/logger';
|
|
6
7
|
|
|
7
8
|
import { BaseService, Service } from './service';
|
|
@@ -40,20 +41,35 @@ export class ConfigServiceImpl extends BaseService {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Get configuration value by path
|
|
44
|
+
* Get configuration value by path with full type inference.
|
|
45
|
+
* Uses module augmentation of OneBunAppConfig for type-safe access.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* // With module augmentation:
|
|
49
|
+
* declare module '@onebun/core' {
|
|
50
|
+
* interface OneBunAppConfig {
|
|
51
|
+
* server: { port: number; host: string };
|
|
52
|
+
* }
|
|
53
|
+
* }
|
|
54
|
+
*
|
|
55
|
+
* const port = configService.get('server.port'); // number
|
|
56
|
+
* const host = configService.get('server.host'); // string
|
|
44
57
|
*/
|
|
45
|
-
get<
|
|
58
|
+
get<P extends DeepPaths<OneBunAppConfig>>(path: P): DeepValue<OneBunAppConfig, P>;
|
|
59
|
+
/** Fallback for dynamic paths */
|
|
60
|
+
get<T = unknown>(path: string): T;
|
|
61
|
+
get(path: string): unknown {
|
|
46
62
|
if (!this.configInstance) {
|
|
47
63
|
throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
|
|
48
64
|
}
|
|
49
65
|
|
|
50
|
-
return this.configInstance.get(path)
|
|
66
|
+
return this.configInstance.get(path);
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
/**
|
|
54
70
|
* Get all configuration values
|
|
55
71
|
*/
|
|
56
|
-
get values():
|
|
72
|
+
get values(): OneBunAppConfig {
|
|
57
73
|
if (!this.configInstance) {
|
|
58
74
|
throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
|
|
59
75
|
}
|
|
@@ -64,7 +80,7 @@ export class ConfigServiceImpl extends BaseService {
|
|
|
64
80
|
/**
|
|
65
81
|
* Get safe configuration for logging (sensitive data masked)
|
|
66
82
|
*/
|
|
67
|
-
getSafeConfig():
|
|
83
|
+
getSafeConfig(): OneBunAppConfig {
|
|
68
84
|
if (!this.configInstance) {
|
|
69
85
|
throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
|
|
70
86
|
}
|