fusion-framework 1.2.2 → 1.2.4

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.
Binary file
Binary file
Binary file
Binary file
package/index.js CHANGED
@@ -134,7 +134,7 @@ header.fingerprint = () =>
134
134
  : {
135
135
  'X-Powered-By': 'Fusion Framework',
136
136
  'X-Framework': 'Fusion',
137
- ['X-Fusion-Version']: '1.2.2',
137
+ ['X-Fusion-Version']: '1.2.4',
138
138
  }
139
139
 
140
140
  function isThenable(value) {
@@ -219,6 +219,121 @@ function resolveRoutePath(routePath, ApiClass) {
219
219
  return native.resolveRoutePathJs(routePath, ApiClass.name)
220
220
  }
221
221
 
222
+ function apiActionName(methodName) {
223
+ if (typeof native.apiActionNameJs === 'function') {
224
+ return native.apiActionNameJs(methodName)
225
+ }
226
+ let stem = methodName
227
+ if (methodName.endsWith('Action') && methodName.length > 6) stem = methodName.slice(0, -6)
228
+ else if (methodName.endsWith('ACTION') && methodName.length > 6) stem = methodName.slice(0, -6)
229
+ return stem.toLowerCase()
230
+ }
231
+
232
+ function resolveMethodRoutePath(template, className, methodName) {
233
+ if (typeof native.resolveMethodRoutePathJs === 'function') {
234
+ return native.resolveMethodRoutePathJs(template, className, methodName)
235
+ }
236
+ return resolveRoutePath(template, { name: className }).replace(
237
+ /\[action\]/g,
238
+ apiActionName(methodName),
239
+ )
240
+ }
241
+
242
+ function joinRoutePaths(base, segment) {
243
+ const left = String(base || '').replace(/\/+$/, '')
244
+ const right = String(segment || '').replace(/^\/+|\/+$/g, '')
245
+ if (!right) return left || '/'
246
+ if (!left) return `/${right}`
247
+ return `${left}/${right}`
248
+ }
249
+
250
+ function resolveHandlerRoute(classBasePath, template, className, methodName) {
251
+ if (typeof native.resolveHandlerRouteJs === 'function') {
252
+ return native.resolveHandlerRouteJs(classBasePath, template, className, methodName)
253
+ }
254
+ const resolved = resolveMethodRoutePath(template, className, methodName)
255
+ if (String(template).startsWith('/')) {
256
+ return joinRoutePaths('', resolved.replace(/^\/+/, ''))
257
+ }
258
+ return joinRoutePaths(classBasePath, resolved)
259
+ }
260
+
261
+ function httpRoute(method, route, options = {}) {
262
+ const meta = {
263
+ method: String(method).toLowerCase(),
264
+ template: route,
265
+ tags: Array.isArray(options.tags) ? options.tags : [],
266
+ desc: options.desc ?? null,
267
+ title: options.title ?? null,
268
+ deprecated: !!options.deprecated,
269
+ }
270
+ function wrap(fn) {
271
+ fn.__fusionHttpRoute = meta
272
+ return fn
273
+ }
274
+ return wrap
275
+ }
276
+
277
+ function httpGet(route, options = {}) {
278
+ return httpRoute('get', route, options)
279
+ }
280
+ function httpPost(route, options = {}) {
281
+ return httpRoute('post', route, options)
282
+ }
283
+ function httpPut(route, options = {}) {
284
+ return httpRoute('put', route, options)
285
+ }
286
+ function httpPatch(route, options = {}) {
287
+ return httpRoute('patch', route, options)
288
+ }
289
+ function httpDelete(route, options = {}) {
290
+ return httpRoute('delete', route, options)
291
+ }
292
+ function httpHead(route, options = {}) {
293
+ return httpRoute('head', route, options)
294
+ }
295
+ function httpOptions(route, options = {}) {
296
+ return httpRoute('options', route, options)
297
+ }
298
+
299
+ function collectRouteSlots(ApiClass, classBasePath, classSwagger) {
300
+ const slots = []
301
+ const custom = new Set()
302
+ const className = ApiClass.name
303
+
304
+ for (const key of Object.getOwnPropertyNames(ApiClass.prototype)) {
305
+ if (key === 'constructor' || key.startsWith('_')) continue
306
+ const fn = ApiClass.prototype[key]
307
+ if (typeof fn !== 'function' || !fn.__fusionHttpRoute) continue
308
+ custom.add(key)
309
+ const meta = fn.__fusionHttpRoute
310
+ slots.push({
311
+ path: resolveHandlerRoute(classBasePath, meta.template, className, key),
312
+ httpMethod: meta.method,
313
+ handlerMethod: key,
314
+ swagger: {
315
+ tags: meta.tags,
316
+ description: meta.desc,
317
+ title: meta.title,
318
+ deprecated: meta.deprecated,
319
+ },
320
+ })
321
+ }
322
+
323
+ for (const methodName of HTTP_METHODS) {
324
+ if (custom.has(methodName)) continue
325
+ if (!definesMethod(ApiClass, methodName)) continue
326
+ slots.push({
327
+ path: classBasePath,
328
+ httpMethod: methodName,
329
+ handlerMethod: methodName,
330
+ swagger: classSwagger,
331
+ })
332
+ }
333
+
334
+ return slots
335
+ }
336
+
222
337
  function emptyRequest() {
223
338
  return {
224
339
  method: '',
@@ -372,17 +487,21 @@ function router(routePath, options = {}) {
372
487
  )
373
488
  }
374
489
 
490
+ const classSwagger = {
491
+ tags: Array.isArray(options.tags) ? options.tags : [],
492
+ description: options.desc ?? null,
493
+ title: options.title ?? null,
494
+ deprecated: !!options.deprecated,
495
+ }
496
+
375
497
  registry.push({
376
498
  path: resolved,
499
+ classBasePath: resolved,
377
500
  ApiClass,
378
501
  middleware: routeMiddleware,
379
- swagger: {
380
- tags: Array.isArray(options.tags) ? options.tags : [],
381
- description: options.desc ?? null,
382
- title: options.title ?? null,
383
- deprecated: !!options.deprecated,
384
- },
502
+ swagger: classSwagger,
385
503
  version_prefix: v,
504
+ slots: collectRouteSlots(ApiClass, resolved, classSwagger),
386
505
  })
387
506
  return ApiClass
388
507
  }
@@ -487,7 +606,9 @@ function readSwaggerSettings() {
487
606
  const navbar = {
488
607
  enabled: truthyEnabled(navbarRaw.enabled, true),
489
608
  showUrlInput: truthyEnabled(navbarRaw.showUrlInput, true),
609
+ showUrlInputSet: Object.prototype.hasOwnProperty.call(navbarRaw, 'showUrlInput'),
490
610
  urls: Array.isArray(navbarRaw.urls) ? navbarRaw.urls : null,
611
+ urlsSet: Array.isArray(navbarRaw.urls),
491
612
  }
492
613
 
493
614
  const ui = {
@@ -532,6 +653,60 @@ function readSwaggerSettings() {
532
653
  }
533
654
  }
534
655
 
656
+ const UNVERSIONED_SWAGGER_NAME = 'default'
657
+
658
+ function normalizeVersionLabel(value) {
659
+ return String(value || '')
660
+ .trim()
661
+ .replace(/^\/+|\/+$/g, '')
662
+ }
663
+
664
+ function collectRouteVersions() {
665
+ const versions = []
666
+ let hasUnversioned = false
667
+ for (const item of registry) {
668
+ const version = normalizeVersionLabel(item.version_prefix)
669
+ if (!version) {
670
+ hasUnversioned = true
671
+ continue
672
+ }
673
+ if (!versions.includes(version)) versions.push(version)
674
+ }
675
+ return { versions, hasUnversioned }
676
+ }
677
+
678
+ function swaggerVersionUrls(prefix) {
679
+ const { versions, hasUnversioned } = collectRouteVersions()
680
+ const urls = versions.map((label) => ({
681
+ url: `${prefix}/${label}/openapi.json`,
682
+ name: label,
683
+ }))
684
+ if (hasUnversioned && urls.length) {
685
+ urls.push({
686
+ url: `${prefix}/${UNVERSIONED_SWAGGER_NAME}/openapi.json`,
687
+ name: UNVERSIONED_SWAGGER_NAME,
688
+ })
689
+ }
690
+ return urls
691
+ }
692
+
693
+ function applyVersionNavbar(swagger) {
694
+ const autoUrls = swaggerVersionUrls(swagger.path)
695
+ if (!swagger.navbar.urlsSet && autoUrls.length) {
696
+ swagger.navbar.urls = autoUrls
697
+ if (!swagger.navbar.showUrlInputSet) swagger.navbar.showUrlInput = false
698
+ }
699
+ return autoUrls.map((item) => item.name)
700
+ }
701
+
702
+ function routeMatchesVersion(item, filter) {
703
+ const version = normalizeVersionLabel(item.version_prefix)
704
+ if (filter == null) return true
705
+ const label = normalizeVersionLabel(filter)
706
+ if (!label || label.toLowerCase() === UNVERSIONED_SWAGGER_NAME) return !version
707
+ return version.toLowerCase() === label.toLowerCase()
708
+ }
709
+
535
710
  function applySwaggerOpenApi(openapi, swagger) {
536
711
  openapi.info = { ...asObject(openapi.info), ...swagger.info }
537
712
  if (swagger.servers?.length) openapi.servers = swagger.servers
@@ -546,7 +721,66 @@ function applySwaggerOpenApi(openapi, swagger) {
546
721
  return openapi
547
722
  }
548
723
 
549
- function swaggerUiHtml(swagger, openapiUrl) {
724
+ function fillOpenApiPaths(openapi, versionFilter = null) {
725
+ const parsePathParams = (pattern) => {
726
+ return String(pattern)
727
+ .split('/')
728
+ .filter((seg) => (seg.startsWith('{') && seg.endsWith('}')) || (seg.startsWith('[') && seg.endsWith(']')))
729
+ .map((seg) => seg.slice(1, -1))
730
+ }
731
+
732
+ for (const item of registry) {
733
+ if (!routeMatchesVersion(item, versionFilter)) continue
734
+ const { ApiClass, swagger: routeSwagger } = item
735
+ const slots = item.slots || []
736
+
737
+ for (const slot of slots) {
738
+ const pathParams = parsePathParams(slot.path)
739
+ const resolvedPath = slot.path.startsWith('/') ? slot.path : `/${slot.path}`
740
+ const routeSwaggerEntry = slot.swagger || routeSwagger
741
+
742
+ if (!openapi.paths[resolvedPath]) openapi.paths[resolvedPath] = {}
743
+
744
+ const methodLower = String(slot.httpMethod).toLowerCase()
745
+ const methodUpper = methodLower.toUpperCase()
746
+ const params = pathParams.map((name) => ({
747
+ name,
748
+ in: 'path',
749
+ required: true,
750
+ schema: { type: 'string' },
751
+ }))
752
+
753
+ openapi.paths[resolvedPath][methodLower] = {
754
+ tags: routeSwaggerEntry?.tags?.length ? routeSwaggerEntry.tags : [],
755
+ summary: routeSwaggerEntry?.title ?? `${ApiClass.name}.${slot.handlerMethod}`,
756
+ description: routeSwaggerEntry?.description ?? '',
757
+ deprecated: !!routeSwaggerEntry?.deprecated,
758
+ operationId: `${ApiClass.name}_${slot.handlerMethod}`,
759
+ parameters: params,
760
+ responses: { 200: { description: 'OK' } },
761
+ }
762
+ }
763
+ }
764
+ return openapi
765
+ }
766
+
767
+ function buildOpenApi(swagger, version = null) {
768
+ const openapi = applySwaggerOpenApi(
769
+ {
770
+ openapi: '3.0.3',
771
+ info: { ...swagger.info },
772
+ paths: {},
773
+ },
774
+ swagger,
775
+ )
776
+ const label = normalizeVersionLabel(version)
777
+ if (label && label !== UNVERSIONED_SWAGGER_NAME) {
778
+ openapi.info = { ...openapi.info, version: label }
779
+ }
780
+ return fillOpenApiPaths(openapi, version)
781
+ }
782
+
783
+ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) {
550
784
  const uiOpts = { ...swagger.ui }
551
785
  delete uiOpts.presets
552
786
  delete uiOpts.plugins
@@ -555,9 +789,12 @@ function swaggerUiHtml(swagger, openapiUrl) {
555
789
  if (swagger.navbar?.urls?.length) {
556
790
  delete uiOpts.url
557
791
  uiOpts.urls = swagger.navbar.urls
792
+ const name = primaryName || swagger.navbar.urls[0]?.name
793
+ if (name) uiOpts['urls.primaryName'] = name
558
794
  } else {
559
795
  uiOpts.url = openapiUrl
560
796
  delete uiOpts.urls
797
+ delete uiOpts['urls.primaryName']
561
798
  }
562
799
  uiOpts.dom_id = '#swagger-ui'
563
800
 
@@ -573,7 +810,7 @@ function swaggerUiHtml(swagger, openapiUrl) {
573
810
  const showUrlInput = swagger.navbar?.showUrlInput !== false
574
811
  const hideUrlCss =
575
812
  navbarEnabled && !showUrlInput
576
- ? `<style>.topbar .download-url-wrapper { display: none !important; }</style>`
813
+ ? `<style>.topbar form { display: none !important; }</style>`
577
814
  : ''
578
815
  const standaloneScript = navbarEnabled
579
816
  ? `<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>`
@@ -633,16 +870,16 @@ class FusionApp {
633
870
  if (this.mounted) return
634
871
  activeGlobalMiddleware = [...this._middleware]
635
872
 
636
- for (const { path: routePath, ApiClass, middleware: routeMiddleware = [] } of registry) {
637
- for (const methodName of HTTP_METHODS) {
638
- if (!definesMethod(ApiClass, methodName)) continue
639
- this.engine.route(methodName.toUpperCase(), routePath, (errOrRequest, maybeRequest) => {
873
+ for (const { ApiClass, middleware: routeMiddleware = [], slots = [] } of registry) {
874
+ for (const slot of slots) {
875
+ const handlerMethod = slot.handlerMethod
876
+ this.engine.route(String(slot.httpMethod).toUpperCase(), slot.path, (errOrRequest, maybeRequest) => {
640
877
  const request = nativeRequestArg(errOrRequest, maybeRequest)
641
878
  const chain = [...activeGlobalMiddleware, ...routeMiddleware]
642
879
  const handler = async (req) => {
643
880
  try {
644
881
  const instance = new ApiClass(req || emptyRequest())
645
- const fn = instance[methodName]
882
+ const fn = instance[handlerMethod]
646
883
  return await Promise.resolve(fn.call(instance))
647
884
  } catch (err) {
648
885
  if (err instanceof HTTPException) return err.toResponse()
@@ -657,64 +894,26 @@ class FusionApp {
657
894
  const swagger = readSwaggerSettings()
658
895
  if (swagger.enabled) {
659
896
  const prefix = swagger.path
897
+ const labels = applyVersionNavbar(swagger)
898
+ const combined = buildOpenApi(swagger)
660
899
 
661
- const openapi = applySwaggerOpenApi(
662
- {
663
- openapi: '3.0.3',
664
- info: { ...swagger.info },
665
- paths: {},
666
- },
667
- swagger,
668
- )
669
-
670
- const parsePathParams = (pattern) => {
671
- return String(pattern)
672
- .split('/')
673
- .filter((seg) => (seg.startsWith('{') && seg.endsWith('}')) || (seg.startsWith('[') && seg.endsWith(']')))
674
- .map((seg) => seg.slice(1, -1))
675
- }
676
-
677
- for (const item of registry) {
678
- const { path: p, ApiClass, swagger: routeSwagger } = item
679
- const pathParams = parsePathParams(p)
680
- const resolvedPath = p.startsWith('/') ? p : `/${p}`
681
-
682
- if (!openapi.paths[resolvedPath]) openapi.paths[resolvedPath] = {}
683
-
684
- for (const methodName of HTTP_METHODS) {
685
- if (!definesMethod(ApiClass, methodName)) continue
686
-
687
- const methodUpper = String(methodName).toUpperCase()
688
- const methodLower = String(methodName).toLowerCase()
689
-
690
- const params = pathParams.map((name) => ({
691
- name,
692
- in: 'path',
693
- required: true,
694
- schema: { type: 'string' },
695
- }))
696
-
697
- openapi.paths[resolvedPath][methodLower] = {
698
- tags: routeSwagger?.tags?.length ? routeSwagger.tags : [],
699
- summary: routeSwagger?.title ?? `${ApiClass.name}.${methodUpper}`,
700
- description: routeSwagger?.description ?? '',
701
- deprecated: !!routeSwagger?.deprecated,
702
- operationId: `${ApiClass.name}_${methodLower}`,
703
- parameters: params,
704
- responses: { '200': { description: 'OK' } },
705
- }
706
- }
707
- }
708
-
709
- const uiEnvelope = () => ({
900
+ const htmlEnvelope = (primaryName = null) => ({
710
901
  status: 200,
711
- body: swaggerUiHtml(swagger, `${prefix}/openapi.json`),
902
+ body: swaggerUiHtml(swagger, `${prefix}/openapi.json`, primaryName),
712
903
  headers: { 'content-type': 'text/html' },
713
904
  })
714
- this.engine.route('GET', `${prefix}/openapi.json`, () => openapi)
715
- this.engine.route('GET', prefix, uiEnvelope)
905
+
906
+ this.engine.route('GET', `${prefix}/openapi.json`, () => combined)
907
+ this.engine.route('GET', prefix, () => htmlEnvelope())
716
908
  if (prefix !== '/') {
717
- this.engine.route('GET', `${prefix}/`, uiEnvelope)
909
+ this.engine.route('GET', `${prefix}/`, () => htmlEnvelope())
910
+ }
911
+
912
+ for (const label of labels) {
913
+ const spec = buildOpenApi(swagger, label)
914
+ this.engine.route('GET', `${prefix}/${label}/openapi.json`, () => spec)
915
+ this.engine.route('GET', `${prefix}/${label}`, () => htmlEnvelope(label))
916
+ this.engine.route('GET', `${prefix}/${label}/`, () => htmlEnvelope(label))
718
917
  }
719
918
  }
720
919
 
@@ -772,8 +971,17 @@ module.exports = {
772
971
  HTTPException,
773
972
  router,
774
973
  route,
974
+ httpGet,
975
+ httpPost,
976
+ httpPut,
977
+ httpPatch,
978
+ httpDelete,
979
+ httpHead,
980
+ httpOptions,
775
981
  apiResourceName,
776
982
  resolveRoutePath,
983
+ resolveHandlerRoute,
984
+ apiActionName,
777
985
  configure,
778
986
  getSettings,
779
987
  settings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusion-framework",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Class-based HTTP framework for Node.js, powered by a shared Rust core via N-API",
5
5
  "keywords": [
6
6
  "http",