     1	/**
     2	 * Express Plugin for Hooked API
     3	 *
     4	 * This plugin creates HTTP endpoints for your REST API by listening to
     5	 * route registrations from the REST API plugin and creating Express routes.
     6	 *
     7	 * Features:
     8	 * - Automatic route creation via addRoute hook
     9	 * - JSON:API compliant request/response handling
    10	 * - Query parameter parsing
    11	 * - Error mapping to HTTP status codes
    12	 * - Content type validation
    13	 * - Middleware injection points
    14	 * - File upload support with busboy or formidable
    15	 */
    16	
    17	import { requirePackage } from 'hooked-api'
    18	import { parseJsonApiQuery } from '../lib/querying-writing/connectors-query-parser.js'
    19	import { createContext } from './lib/request-helpers.js'
    20	import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
    21	
    22	export const ExpressPlugin = {
    23	  name: 'express',
    24	  dependencies: ['rest-api'],
    25	
    26	  async install ({ on, vars, helpers, pluginOptions, log, scopes, api, runHooks, addHook }) {
    27	    addHook('release', 'releaseHook', {},
    28	      async ({ api }) => {} // TODO: Anything to do here?
    29	    )
    30	
    31	    // Dynamic import for Express
    32	    let express
    33	    try {
    34	      express = (await import('express')).default
    35	    } catch (e) {
    36	      requirePackage('express', 'express',
    37	        'Express.js is required for HTTP server functionality. This is a peer dependency.')
    38	    }
    39	    // Enhance the logger
    40	    const enhancedLog = createEnhancedLogger(log, {
    41	      logFullErrors: true,
    42	      includeStack: true
    43	    })
    44	
    45	    // Initialize express namespace
    46	    if (!api.http) {
    47	      api.http = {}
    48	    }
    49	    api.http.express = {}
    50	
    51	    const expressOptions = pluginOptions || {}
    52	
    53	    // Get mountPath from options (this is now a transport concern)
    54	    const mountPath = expressOptions.mountPath || ''
    55	    const basePath = mountPath // Keep basePath for internal use
    56	    const strictContentType = expressOptions.strictContentType !== false
    57	    const requestSizeLimit = expressOptions.requestSizeLimit || '1mb'
    58	
    59	    // Set transport information for other plugins
    60	    vars.transport = {
    61	      type: 'express',
    62	      matchAll: '*', // Express wildcard pattern for matching all routes
    63	      mountPath // Transport-specific mount path
    64	    }
    65	
    66	    // Register file detector if enabled
    67	    if (expressOptions.enableFileUploads !== false && api.rest?.registerFileDetector) {
    68	      const parserLib = expressOptions.fileParser || 'busboy'
    69	      const parserOptions = expressOptions.fileParserOptions || {}
    70	
    71	      let detector
    72	
    73	      if (parserLib === 'busboy') {
    74	        try {
    75	          const { createBusboyDetector } = await import('../lib/busboy-detector.js')
    76	          detector = createBusboyDetector(parserOptions)
    77	        } catch (e) {
    78	          log.warn('Busboy not installed. Install with: npm install busboy')
    79	        }
    80	      } else if (parserLib === 'formidable') {
    81	        try {
    82	          const { createFormidableDetector } = await import('../lib/formidable-detector.js')
    83	          detector = createFormidableDetector(parserOptions)
    84	        } catch (e) {
    85	          log.warn('Formidable not installed. Install with: npm install formidable')
    86	        }
    87	      }
    88	
    89	      if (detector) {
    90	        api.rest.registerFileDetector({
    91	          name: `express-${detector.name}`,
    92	          detect: (params, context) => {
    93	            if (!context || !context.raw || !context.raw.req) return false
    94	            const detectParams = { ...params, _expressReq: context.raw.req }
    95	            return detector.detect(detectParams)
    96	          },
    97	          parse: (params, context) => {
    98	            const parseParams = { ...params, _expressReq: context.raw.req, _expressRes: context.raw.res }
    99	            return detector.parse(parseParams)
   100	          }
   101	        })
   102	        log.info(`Express plugin registered file detector: ${detector.name}`)
   103	      }
   104	    }
   105	
   106	    // Create Express routers
   107	    const router = expressOptions.router || express.Router()
   108	    const notFoundRouter = express.Router()
   109	
   110	    // Add body parsing middleware
   111	    router.use(express.json({
   112	      limit: requestSizeLimit,
   113	      type: ['application/json', 'application/vnd.api+json']
   114	    }))
   115	
   116	    // Add transport hook middleware
   117	    router.use(async (req, res, next) => {
   118	      const context = createContext(req, res, 'express')
   119	
   120	      // Check for Express middleware override first
   121	      if (req.urlPrefixOverride) {
   122	        context.urlPrefixOverride = req.urlPrefixOverride
   123	      }
   124	
   125	      // Calculate and cache URL prefix (getUrlPrefix handles all logic)
   126	      const { getUrlPrefix } = await import('../lib/querying/url-helpers.js')
   127	      context.urlPrefix = getUrlPrefix(context, { vars: { transport: { mountPath: basePath } } }, req)
   128	
   129	      // Transport-specific data for hooks
   130	      const transportData = {
   131	        request: {
   132	          method: req.method,
   133	          url: req.url,
   134	          path: req.path,
   135	          headers: req.headers,
   136	          body: req.body,
   137	          params: req.params,
   138	          query: req.query
   139	        },
   140	        response: {
   141	          headers: {},
   142	          status: null
   143	        }
   144	      }
   145	
   146	      // Add transport data to context
   147	      context.transport = transportData
   148	
   149	      // Run transport hooks - don't check return value for consistency with other hooks
   150	      // Hooks communicate via context flags (rejection, handled) not return values
   151	      await runHooks('transport:request', context)
   152	
   153	      // Check if request was rejected (e.g., authentication failure)
   154	      if (context.rejection) {
   155	        // Apply response headers from hooks
   156	        if (transportData.response.headers) {
   157	          res.set(transportData.response.headers)
   158	        }
   159	        return res.status(context.rejection.status || 500).json({
   160	          errors: [{
   161	            status: String(context.rejection.status || 500),
   162	            title: context.rejection.title || 'Request Rejected',
   163	            detail: context.rejection.message
   164	          }]
   165	        })
   166	      }
   167	
   168	      // Check if request was already handled by a hook
   169	      if (context.handled) {
   170	        return
   171	      }
   172	
   173	      // Store transport data and context for later use
   174	      req.transportData = transportData
   175	      req.context = context
   176	      next()
   177	    })
   178	
   179	    // Content type validation middleware
   180	    if (strictContentType) {
   181	      router.use((req, res, next) => {
   182	        if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
   183	          const contentType = req.get('Content-Type')
   184	
   185	          if (contentType && !contentType.includes('application/vnd.api+json') &&
   186	              !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
   187	            return res.status(415).json({
   188	              errors: [{
   189	                status: '415',
   190	                title: 'Unsupported Media Type',
   191	                detail: 'Content-Type must be application/vnd.api+json or application/json'
   192	              }]
   193	            })
   194	          }
   195	        }
   196	        next()
   197	      })
   198	    }
   199	
   200	    /**
   201	     * Error handler - maps REST API errors to HTTP responses
   202	     */
   203	    const handleError = async (error, req, res) => {
   204	      enhancedLog.logError('HTTP request error', error, {
   205	        method: req.method,
   206	        path: req.path,
   207	        url: req.url
   208	      })
   209	
   210	      let status = 500
   211	      const errorResponse = {
   212	        errors: [{
   213	          status: '500',
   214	          title: 'Internal Server Error',
   215	          detail: error.message
   216	        }]
   217	      }
   218	
   219	      // Map error codes to HTTP status
   220	      if (error.code === 'REST_API_VALIDATION') {
   221	        status = 422
   222	        errorResponse.errors = [{
   223	          status: '422',
   224	          title: 'Validation Error',
   225	          detail: error.message,
   226	          source: error.details
   227	        }]
   228	        if (error.details?.violations) {
   229	          errorResponse.errors = error.details.violations.map(v => ({
   230	            status: '422',
   231	            title: 'Validation Error',
   232	            detail: v.message,
   233	            source: { pointer: v.field }
   234	          }))
   235	        }
   236	      } else if (error.code === 'REST_API_RESOURCE') {
   237	        switch (error.subtype) {
   238	          case 'not_found':
   239	            status = 404
   240	            errorResponse.errors[0].status = '404'
   241	            errorResponse.errors[0].title = 'Not Found'
   242	            break
   243	          case 'conflict':
   244	            status = 409
   245	            errorResponse.errors[0].status = '409'
   246	            errorResponse.errors[0].title = 'Conflict'
   247	            break
   248	          case 'forbidden':
   249	            status = 403
   250	            errorResponse.errors[0].status = '403'
   251	            errorResponse.errors[0].title = 'Forbidden'
   252	            break
   253	          default:
   254	            status = 400
   255	            errorResponse.errors[0].status = '400'
   256	            errorResponse.errors[0].title = 'Bad Request'
   257	        }
   258	      } else if (error.code === 'REST_API_PAYLOAD') {
   259	        status = 400
   260	        errorResponse.errors = [{
   261	          status: '400',
   262	          title: 'Bad Request',
   263	          detail: error.message,
   264	          source: { pointer: error.path }
   265	        }]
   266	      }
   267	
   268	      // Run transport:response hook for errors
   269	      if (req.transportData && req.context) {
   270	        req.transportData.response.status = status
   271	        req.transportData.response.body = errorResponse
   272	        req.context.transport = req.transportData
   273	        await runHooks('transport:response', req.context)
   274	
   275	        // Apply response headers from hooks
   276	        if (req.transportData.response.headers) {
   277	          res.set(req.transportData.response.headers)
   278	        }
   279	      }
   280	
   281	      res.status(status).json(errorResponse)
   282	    }
   283	
   284	    /**
   285	     * Listen to addRoute hook to create Express routes
   286	     */
   287	    addHook('addRoute', 'expressRouteCreator', {}, async ({ context }) => {
   288	      const { method, path, handler } = context
   289	
   290	      // Apply any global before middleware
   291	      const beforeMiddleware = expressOptions.middleware?.beforeAll || []
   292	
   293	      try {
   294	        // Extract the handler logic into a shared function to keep it DRY (Don't Repeat Yourself).
   295	        const expressHandler = async (req, res) => {
   296	          try {
   297	            // Extract request data
   298	            const queryString = req.url.split('?')[1] || ''
   299	            const context = req.context || createContext(req, res, 'express')
   300	
   301	            // Call the generic handler
   302	            const result = await handler({
   303	              queryString,
   304	              headers: req.headers,
   305	              params: req.params,
   306	              body: req.body,
   307	              context
   308	            })
   309	
   310	            // Prepare response status
   311	            let responseStatus = 200
   312	            if (result && typeof result.statusCode === 'number') {
   313	              responseStatus = result.statusCode
   314	            } else if (req.method === 'POST' && result) {
   315	              // POST with content returns 201 Created
   316	              responseStatus = 201
   317	            } else if (req.method === 'POST' && !result) {
   318	              // POST without content returns 204 No Content
   319	              responseStatus = 204
   320	            } else if (req.method === 'DELETE') {
   321	              // DELETE always returns 204 No Content
   322	              responseStatus = 204
   323	            } else if ((req.method === 'PUT' || req.method === 'PATCH') && !result) {
   324	              // PUT/PATCH return 204 when no content is returned
   325	              responseStatus = 204
   326	            }
   327	
   328	            // Apply headers from the handler result
   329	            if (result && result.headers) {
   330	              res.set(result.headers)
   331	            }
   332	
   333	            // Update transport data for response (if transport data exists)
   334	            if (req.transportData) {
   335	              req.transportData.response.status = responseStatus
   336	              req.transportData.response.body = result
   337	              context.transport = req.transportData
   338	              await runHooks('transport:response', context)
   339	              if (req.transportData.response.headers) {
   340	                res.set(req.transportData.response.headers)
   341	              }
   342	            }
   343	
   344	            // Set content type
   345	            res.set('Content-Type', 'application/vnd.api+json')
   346	
   347	            // Set Location header for successful POST requests
   348	            if (req.method === 'POST' && context.id) {
   349	              // Extract scopeName from the path (e.g., /api/countries -> countries)
   350	              const pathParts = path.split('/')
   351	              const scopeName = pathParts[pathParts.length - 1]
   352	
   353	              // Set Location header for any successful POST (201 or 204)
   354	              if (helpers.getLocation) {
   355	                const location = helpers.getLocation({ scopeName, id: context.id })
   356	                const baseUrl = context.urlPrefix || basePath
   357	                res.set('Location', `${baseUrl}${location}`)
   358	              }
   359	            }
   360	
   361	            // Handle response based on status
   362	            if (responseStatus === 204) {
   363	              res.sendStatus(204)
   364	            } else {
   365	              // If result has a body property, that's what we should send
   366	              // This happens when handler returns { statusCode, body, headers }
   367	              const responseBody = result && result.body !== undefined ? result.body : result
   368	              res.status(responseStatus).json(responseBody)
   369	            }
   370	          } catch (error) {
   371	            handleError(error, req, res)
   372	          }
   373	        }
   374	
   375	        // CRITICAL: Express routing method selection - wildcard vs specific routes
   376	        //
   377	        // Express provides two different ways to register routes:
   378	        // 1. router.METHOD(path, handler) - e.g., router.get('/users', handler)
   379	        //    - Only responds to the SPECIFIC HTTP method
   380	        //    - Perfect for normal REST endpoints
   381	        //
   382	        // 2. router.use(path, handler)
   383	        //    - Responds to ALL HTTP methods
   384	        //    - Needed for wildcard paths that must handle any method
   385	        //
   386	        // The CORS plugin needs wildcard routes because it must handle OPTIONS
   387	        // requests for ANY path under the API prefix, even paths that don't exist
   388	        // as defined routes. For example:
   389	        // - Defined route: GET /api/users
   390	        // - Browser might send: OPTIONS /api/users/invalid/path
   391	        // - CORS must still respond with proper headers
   392	        //
   393	        if (path === vars.transport.matchAll) {
   394	          // This is a wildcard route (path = '*')
   395	          // We MUST use router.use() because:
   396	          // - We need to catch ALL paths (using '*' or '/api/*')
   397	          // - We need to handle a SPECIFIC method (e.g., OPTIONS)
   398	          // - router.options('*') would NOT work for paths like '/api/some/nested/path'
   399	
   400	          // Since router.use() responds to ALL methods, we need a wrapper
   401	          // that only handles our specific method (e.g., OPTIONS)
   402	          const methodSpecificMiddleware = (req, res, next) => {
   403	            if (req.method.toLowerCase() === method.toLowerCase()) {
   404	              expressHandler(req, res)
   405	            } else {
   406	              // Not our method, pass to next middleware
   407	              next()
   408	            }
   409	          }
   410	          // IMPORTANT: We do NOT need to pass a path to router.use() here!
   411	          // When no path is provided, router.use() matches ALL requests
   412	          // This is exactly what we want for wildcard routes
   413	          router.use(...beforeMiddleware, methodSpecificMiddleware)
   414	        } else {
   415	          // This is a normal route with a specific path (e.g., '/api/users')
   416	          // We use router.METHOD() because:
   417	          // - We want to respond to ONLY this specific HTTP method
   418	          // - The path is exact, not a wildcard
   419	          // - This is more efficient than router.use() with method checking
   420	          //
   421	          // Note: Routes from RestApiPlugin already include the full path with mountPath
   422	          // So we use them as-is without adding basePath to avoid double-prefixing
   423	
   424	          router[method.toLowerCase()](path, ...beforeMiddleware, expressHandler)
   425	
   426	          // Debug logging for route registration
   427	
   428	          // Check if the route was actually added
   429	          if (router.stack) {
   430	            const lastRoute = router.stack[router.stack.length - 1]
   431	          }
   432	        }
   433	      } catch (routeError) {
   434	        log.error('[EXPRESS DEBUG] Error creating route:', {
   435	          error: routeError.message,
   436	          stack: routeError.stack,
   437	          path,
   438	          method: method.toLowerCase()
   439	        })
   440	        throw routeError
   441	      }
   442	
   443	      const logPath = path === vars.transport.matchAll
   444	        ? '(all paths)'  // router.use() with no path matches everything
   445	        : path
   446	      log.trace(`Express route created: ${method} ${logPath}`)
   447	    })
   448	
   449	    // Apply global middleware if configured
   450	    let finalRouter = router
   451	    if (expressOptions.middleware?.beforeAll) {
   452	      const globalRouter = express.Router()
   453	      globalRouter.use(...expressOptions.middleware.beforeAll)
   454	      globalRouter.use(router)
   455	      finalRouter = globalRouter
   456	    }
   457	
   458	    // Set up 404 handler in separate router (unless disabled)
   459	    if (expressOptions.handle404 !== false) {
   460	      notFoundRouter.use(async (req, res, next) => {
   461	        if (basePath && req.path.startsWith(basePath)) {
   462	          // Create minimal context for 404
   463	          const context = createContext(req, res, 'express')
   464	          const transportData = {
   465	            request: {
   466	              method: req.method,
   467	              url: req.url,
   468	              path: req.path,
   469	              headers: req.headers
   470	            },
   471	            response: {
   472	              headers: {},
   473	              status: 404,
   474	              body: {
   475	                errors: [{
   476	                  status: '404',
   477	                  title: 'Not Found',
   478	                  detail: `The requested endpoint ${req.method} ${req.path} does not exist`
   479	                }]
   480	              }
   481	            }
   482	          }
   483	
   484	          // Add transport data to context
   485	          context.transport = transportData
   486	
   487	          // Run transport:response hook for 404
   488	          await runHooks('transport:response', context)
   489	
   490	          // Apply response headers from hooks
   491	          if (transportData.response.headers) {
   492	            res.set(transportData.response.headers)
   493	          }
   494	
   495	          res.status(404).json(transportData.response.body)
   496	        } else {
   497	          next()
   498	        }
   499	      })
   500	    }
   501	
   502	    // Store routers in api.http.express namespace
   503	    api.http.express.router = finalRouter
   504	    api.http.express.notFoundRouter = notFoundRouter
   505	
   506	    // Allow other plugins to add middleware before 404 handler
   507	    const beforeNotFoundMiddleware = []
   508	    api.http.express.beforeNotFound = (middleware) => {
   509	      beforeNotFoundMiddleware.push(middleware)
   510	    }
   511	
   512	    // Add convenient mounting method
   513	    api.http.express.mount = (app, path = '') => {
   514	      // Mount main router with all routes
   515	      app.use(path, finalRouter)
   516	
   517	      // Mount any middleware that should come before 404
   518	      beforeNotFoundMiddleware.forEach(middleware => {
   519	        app.use(path, middleware)
   520	      })
   521	
   522	      // Mount 404 handler router after all other routes (if enabled)
   523	      if (expressOptions.handle404 !== false) {
   524	        app.use(path, notFoundRouter)
   525	      }
   526	
   527	      log.info(`Express routes mounted at ${path || '/'}`)
   528	    }
   529	
   530	    log.info('Express plugin initialized successfully')
   531	  }
   532	}
