diff --git a/app/bridge/bridge_context.prg b/app/bridge/bridge_context.prg new file mode 100644 index 0000000..2fcdfdb --- /dev/null +++ b/app/bridge/bridge_context.prg @@ -0,0 +1,78 @@ +/* + * bridge_context.prg - FiveNode Request Context (Harbour side) + * JSON-based context access, output buffer wrappers + * + * Copyright (c) 2026 Charles KWON ( Charles KWON Ohjun, charleskwonohjun@gmail.com ) + * All rights reserved. + */ + +FUNCTION BRIDGE_SETUP_ERRORBLOCK() + + LOCAL nH := FCreate( hb_DirTemp() + "errblock_proof.txt" ) + + FWrite( nH, "ErrorBlock SET at " + Time() ) + FClose( nH ) + + ErrorBlock( {| e | Break( e ) } ) + +RETURN NIL + +FUNCTION BRIDGE_PING() + + hb_MemoWrit( hb_DirTemp() + "ping_proof.txt", "PING_EXECUTED" ) + _BRIDGE_SET_RESULT( "PONG" ) + +RETURN NIL + +FUNCTION hb_bridge_get_output() +RETURN _OUT_GET() + +FUNCTION hb_bridge_clear_output() + + _OUT_CLEAR() + +RETURN NIL + +FUNCTION bridge_output_append( cText ) + + _OUT_APPEND( cText ) + +RETURN NIL + +// ── Context value accessor (extract from JSON via hb_jsonDecode) ── + +FUNCTION ctx_get( cKey, xDefault ) + + LOCAL cJson := _CTX_GET_JSON() + LOCAL hCtx + + IF Empty( cJson ) + RETURN xDefault + ENDIF + + hb_jsonDecode( cJson, @hCtx ) + + IF ValType( hCtx ) == "H" .AND. hb_HHasKey( hCtx, cKey ) + RETURN hCtx[ cKey ] + ENDIF + +RETURN xDefault + +FUNCTION ctx_set( cKey, xValue ) + + LOCAL cJson := _CTX_GET_JSON() + LOCAL hCtx + + IF Empty( cJson ) + hCtx := { => } + ELSE + hb_jsonDecode( cJson, @hCtx ) + IF ValType( hCtx ) != "H" + hCtx := { => } + ENDIF + ENDIF + + hCtx[ cKey ] := xValue + _CTX_SET_JSON( hb_jsonEncode( hCtx ) ) + +RETURN xValue diff --git a/app/bridge/bridge_cookie.prg b/app/bridge/bridge_cookie.prg new file mode 100644 index 0000000..df5e52a --- /dev/null +++ b/app/bridge/bridge_cookie.prg @@ -0,0 +1,109 @@ +/* + * bridge_cookie.prg - FiveNode + * + * Copyright (c) 2026 Charles KWON ( Charles KWON Ohjun, charleskwonohjun@gmail.com ) + * All rights reserved. + */ + +// bridge_cookie.prg +// Cookie functions (6) - mod_harbourv2 100% compatible + +FUNCTION fn_GetCookies( cKey ) + + LOCAL hCookies := _parse_cookies() + + IF cKey == NIL + RETURN hCookies + ENDIF + + IF hb_HHasKey( hCookies, cKey ) + RETURN hCookies[ cKey ] + ENDIF + +RETURN "" + +FUNCTION AP_COOKIE_READ( cName ) +RETURN fn_GetCookies( cName ) + +FUNCTION fn_SetCookie( cName, cValue, nSecs, cPath, cDomain, lHttps, lOnlyHttp ) + + LOCAL cCookie, aCookies + + hb_default( @cPath, "/" ) + hb_default( @lHttps, .F. ) + hb_default( @lOnlyHttp, .F. ) + + cCookie := cName + "=" + cValue + + IF nSecs != NIL + cCookie += "; Max-Age=" + hb_ntos( nSecs ) + ENDIF + + cCookie += "; Path=" + cPath + + IF cDomain != NIL + cCookie += "; Domain=" + cDomain + ENDIF + + IF lHttps + cCookie += "; Secure" + ENDIF + + IF lOnlyHttp + cCookie += "; HttpOnly" + ENDIF + + aCookies := ctx_get( "cookies_out", {} ) + AAdd( aCookies, cCookie ) + ctx_set( "cookies_out", aCookies ) + +RETURN NIL + +FUNCTION AP_COOKIE_WRITE( cName, cValue, nExpire ) +RETURN fn_SetCookie( cName, cValue, nExpire ) + +FUNCTION AP_COOKIE_REMOVE( cName ) +RETURN fn_SetCookie( cName, "", 0 ) + +FUNCTION AP_COOKIE_CHECK_STRING( cString ) + + IF Empty( cString ) + RETURN .F. + ENDIF + + IF ";" $ cString .OR. Chr(13) $ cString .OR. Chr(10) $ cString + RETURN .F. + ENDIF + +RETURN .T. + +// ── Cookie parsing helper ── + +STATIC FUNCTION _parse_cookies() + + LOCAL hHeaders, cRaw, aParts, cPart, aPair + LOCAL hCookies := { => } + + hHeaders := AP_HEADERSIN() + + IF hb_HHasKey( hHeaders, "Cookie" ) + cRaw := hHeaders[ "Cookie" ] + aParts := hb_ATokens( cRaw, ";" ) + FOR EACH cPart IN aParts + cPart := AllTrim( cPart ) + aPair := hb_ATokens( cPart, "=" ) + IF Len( aPair ) >= 2 + hCookies[ AllTrim( aPair[1] ) ] := AllTrim( aPair[2] ) + ENDIF + NEXT + ENDIF + +RETURN hCookies + +// -- mod_harbour compatibility wrappers -- + +FUNCTION MH_GetCookies( cKey ) +RETURN fn_GetCookies( cKey ) + +FUNCTION MH_SetCookie( cName, cValue, nSecs, cPath, cDomain, lHttps, lOnlyHttp ) +RETURN fn_SetCookie( cName, cValue, nSecs, cPath, cDomain, lHttps, lOnlyHttp ) diff --git a/app/bridge/bridge_request.prg b/app/bridge/bridge_request.prg new file mode 100644 index 0000000..59c9e37 --- /dev/null +++ b/app/bridge/bridge_request.prg @@ -0,0 +1,342 @@ +/* + * bridge_request.prg - FiveNode + * + * Copyright (c) 2026 Charles KWON ( Charles KWON Ohjun, charleskwonohjun@gmail.com ) + * All rights reserved. + */ + +// bridge_request.prg +// AP_* function reimplementation - 100% identical function names/signatures + +// ── Request functions ── + +FUNCTION AP_METHOD() +RETURN ctx_get( "method", "GET" ) + +FUNCTION AP_FILENAME() +RETURN ctx_get( "filename", "" ) + +FUNCTION AP_ARGS() +RETURN ctx_get( "query_string", "" ) + +FUNCTION AP_USERIP() +RETURN ctx_get( "remote_ip", "127.0.0.1" ) + +FUNCTION AP_BODY() +RETURN ctx_get( "body", "" ) + +FUNCTION AP_GETBODY() +RETURN ctx_get( "body", "" ) + +FUNCTION AP_GETENV( cVarName ) + + LOCAL hEnv := ctx_get( "env", { => } ) + + IF hb_HHasKey( hEnv, Upper( cVarName ) ) + RETURN hEnv[ Upper( cVarName ) ] + ENDIF + +RETURN hb_GetEnv( cVarName, "" ) + +// ── Headers In ── + +FUNCTION AP_HEADERSIN() +RETURN ctx_get( "headers_in", { => } ) + +FUNCTION AP_HEADERSINCOUNT() +RETURN Len( ctx_get( "headers_in", { => } ) ) + +FUNCTION AP_HEADERSINKEY( n ) + + LOCAL hHeaders := ctx_get( "headers_in", { => } ) + + IF n >= 1 .AND. n <= Len( hHeaders ) + RETURN hb_HKeyAt( hHeaders, n ) + ENDIF + +RETURN "" + +FUNCTION AP_HEADERSINVAL( n ) + + LOCAL hHeaders := ctx_get( "headers_in", { => } ) + + IF n >= 1 .AND. n <= Len( hHeaders ) + RETURN hb_HValueAt( hHeaders, n ) + ENDIF + +RETURN "" + +// ── Headers Out ── + +FUNCTION AP_HEADERSOUT() +RETURN ctx_get( "headers_out", { => } ) + +FUNCTION AP_HEADERSOUTCOUNT() +RETURN Len( ctx_get( "headers_out", { => } ) ) + +FUNCTION AP_HEADERSOUTKEY( n ) + + LOCAL hHeaders := ctx_get( "headers_out", { => } ) + + IF n >= 1 .AND. n <= Len( hHeaders ) + RETURN hb_HKeyAt( hHeaders, n ) + ENDIF + +RETURN "" + +FUNCTION AP_HEADERSOUTVAL( n ) + + LOCAL hHeaders := ctx_get( "headers_out", { => } ) + + IF n >= 1 .AND. n <= Len( hHeaders ) + RETURN hb_HValueAt( hHeaders, n ) + ENDIF + +RETURN "" + +FUNCTION AP_HEADERSOUTSET( cKey, cValue ) + + LOCAL hHeaders := ctx_get( "headers_out", { => } ) + + hHeaders[ cKey ] := cValue + ctx_set( "headers_out", hHeaders ) + +RETURN NIL + +FUNCTION AP_SETCONTENTTYPE( cType ) +RETURN AP_HEADERSOUTSET( "Content-Type", cType ) + +// ── Output ── + +// fivenode_go patch: collapsed (...) -> single arg. Five's PValue returns +// the caller's LOCALs rather than the actual variadic args (declared +// params are 0 for `(...)`, so the runtime never copies args into the +// locals slot PValue reads). Every call site in this codebase passes +// exactly one value, so the collapse is behaviour-preserving. +// TODO: revert once Five gains true variadic PValue support. +FUNCTION AP_RPUTS( u ) + IF ValType( u ) == "C" + bridge_output_append( u ) + ELSE + bridge_output_append( fn_ValToChar( u ) ) + ENDIF +RETURN NIL + +FUNCTION AP_ECHO( u ) + IF ValType( u ) == "C" + bridge_output_append( u ) + ELSEIF ValType( u ) != "U" + bridge_output_append( fn_ValToChar( u ) ) + ENDIF +RETURN NIL + +// ── Parameter Parsing ── + +FUNCTION AP_GetPairs( lUrlDecode ) + + LOCAL cArgs, aParams, aPair, hResult, cParam + + hb_default( @lUrlDecode, .T. ) + hResult := { => } + cArgs := AP_ARGS() + + IF ! Empty( cArgs ) + aParams := hb_ATokens( cArgs, "&" ) + FOR EACH cParam IN aParams + aPair := hb_ATokens( cParam, "=" ) + IF Len( aPair ) == 2 + hResult[ aPair[ 1 ] ] := iif( lUrlDecode, hb_UrlDecode( aPair[ 2 ] ), aPair[ 2 ] ) + ELSEIF Len( aPair ) == 1 + hResult[ aPair[ 1 ] ] := "" + ENDIF + NEXT + ENDIF + +RETURN hResult + +FUNCTION AP_PostPairs( lUrlDecode ) + + LOCAL cBody, aParams, aPair, hResult, cParam + + hb_default( @lUrlDecode, .T. ) + hResult := { => } + cBody := AP_BODY() + + IF ! Empty( cBody ) + aParams := hb_ATokens( cBody, "&" ) + FOR EACH cParam IN aParams + aPair := hb_ATokens( cParam, "=" ) + IF Len( aPair ) == 2 + hResult[ aPair[ 1 ] ] := iif( lUrlDecode, hb_UrlDecode( aPair[ 2 ] ), aPair[ 2 ] ) + ELSEIF Len( aPair ) == 1 + hResult[ aPair[ 1 ] ] := "" + ENDIF + NEXT + ENDIF + +RETURN hResult + +// ── URL decode ── + +FUNCTION hb_UrlDecode( cString ) + + LOCAL cResult := "", i, c + + cString := StrTran( cString, "+", " " ) + i := 1 + + DO WHILE i <= Len( cString ) + c := SubStr( cString, i, 1 ) + IF c == "%" .AND. i + 2 <= Len( cString ) + cResult += Chr( hb_HexToNum( SubStr( cString, i + 1, 2 ) ) ) + i += 3 + ELSE + cResult += c + i++ + ENDIF + ENDDO + +RETURN cResult + +// ── Path/URL ── + +FUNCTION fn_PathUrl() +RETURN ctx_get( "uri", "/" ) + +FUNCTION fn_PathBase( cDirFile ) + + LOCAL cRoot := ctx_get( "document_root", "." ) + + IF cDirFile != NIL + RETURN cRoot + "/" + cDirFile + ENDIF + +RETURN cRoot + +FUNCTION fn_Include( cFile ) +RETURN hb_MemoRead( fn_PathBase( cFile ) ) + +FUNCTION fn_GetUri() + + LOCAL cScheme := iif( ctx_get( "https", .F. ), "https", "http" ) + LOCAL cHost := "" + LOCAL hHeaders := AP_HEADERSIN() + + IF hb_HHasKey( hHeaders, "Host" ) + cHost := hHeaders[ "Host" ] + ENDIF + +RETURN cScheme + "://" + cHost + ctx_get( "uri", "/" ) + +FUNCTION fn_Redirect( cUrl ) + + AP_HEADERSOUTSET( "Location", cUrl ) + ctx_set( "status", 302 ) + +RETURN NIL + +// ── Data Conversion ── + +FUNCTION fn_ValToChar( u ) + + LOCAL cType := ValType( u ) + + SWITCH cType + CASE "C" ; RETURN u + CASE "N" ; RETURN hb_ntos( u ) + CASE "D" ; RETURN DToC( u ) + CASE "L" ; RETURN iif( u, ".T.", ".F." ) + CASE "U" ; RETURN "NIL" + CASE "A" ; RETURN hb_jsonEncode( u ) + CASE "H" ; RETURN hb_jsonEncode( u ) + CASE "T" ; RETURN hb_TToC( u ) + ENDSWITCH + +RETURN "(" + cType + ")" + +// ctx_get / ctx_set are defined as PUBLIC in bridge_context.prg + +// -- mod_harbour compatibility wrappers -- + +FUNCTION MH_PathUrl() +RETURN fn_PathUrl() + +FUNCTION MH_PathBase( cDirFile ) +RETURN fn_PathBase( cDirFile ) + +FUNCTION MH_Include( cFile ) +RETURN fn_Include( cFile ) + +FUNCTION mh_GetUri() +RETURN fn_GetUri() + +FUNCTION mh_Redirect( cUrl ) +RETURN fn_Redirect( cUrl ) + +FUNCTION MH_ValToChar( u ) +RETURN fn_ValToChar( u ) + +// ── JSON Response helpers ── + +FUNCTION AP_JSONRESPONSE( xData, nStatus ) + + IF nStatus != NIL + ctx_set( "status", nStatus ) + ENDIF + + AP_SETCONTENTTYPE( "application/json" ) + AP_RPUTS( hb_jsonEncode( xData ) ) + +RETURN NIL + +FUNCTION AP_SETSTATUS( nStatus ) + + ctx_set( "status", nStatus ) + +RETURN NIL + +// ── Auth (read-only from .prg, set by Node.js only) ── + +FUNCTION fn_Auth() + + LOCAL cAuth := _AUTH_GET() + LOCAL hAuth + + IF Empty( cAuth ) + RETURN { "authenticated" => .F. } + ENDIF + + hb_jsonDecode( cAuth, @hAuth ) + + IF ValType( hAuth ) != "H" + RETURN { "authenticated" => .F. } + ENDIF + +RETURN hAuth + +FUNCTION fn_IsAuth() +RETURN fn_HGetDef( fn_Auth(), "authenticated", .F. ) + +FUNCTION fn_AuthUser() +RETURN fn_HGetDef( fn_Auth(), "user", "" ) + +FUNCTION fn_AuthRole() +RETURN fn_HGetDef( fn_Auth(), "role", "" ) + +// ── Hash helper ── + +FUNCTION fn_HGetDef( hHash, cKey, xDefault ) +RETURN hb_HGetDef( hHash, cKey, xDefault ) + +// ── JSON Body helper ── + +FUNCTION AP_JSONBODY() + + LOCAL cBody := AP_BODY() + LOCAL xResult + + IF ! Empty( cBody ) + hb_jsonDecode( cBody, @xResult ) + ENDIF + +RETURN xResult diff --git a/app/bridge/bridge_session.prg b/app/bridge/bridge_session.prg new file mode 100644 index 0000000..6fe70a1 --- /dev/null +++ b/app/bridge/bridge_session.prg @@ -0,0 +1,266 @@ +/* + * bridge_session.prg - FiveNode + * + * Copyright (c) 2026 Charles KWON ( Charles KWON Ohjun, charleskwonohjun@gmail.com ) + * All rights reserved. + */ + +#include "hbclass.ch" + +// bridge_session.prg +// Session management - mod_harbourv2 MH_Sessions 100% compatible + +THREAD STATIC ts_oSession + +// ── Public API ── + +FUNCTION fn_SessionInit( cName, nExpired ) + + hb_default( @cName, "MHSID" ) + hb_default( @nExpired, 3600 ) + + ts_oSession := MH_Sessions():New( cName, nExpired ) + ts_oSession:Init() + +RETURN NIL + +FUNCTION fn_Session( cKey, uValue ) + + IF ts_oSession == NIL + RETURN NIL + ENDIF + + IF cKey == NIL + RETURN ts_oSession:hData + ENDIF + + IF uValue != NIL + ts_oSession:hData[ cKey ] := uValue + RETURN uValue + ENDIF + + IF hb_HHasKey( ts_oSession:hData, cKey ) + RETURN ts_oSession:hData[ cKey ] + ENDIF + +RETURN NIL + +FUNCTION fn_SessionWrite() + + IF ts_oSession != NIL + ts_oSession:Write() + ENDIF + +RETURN NIL + +FUNCTION fn_SessionEnd() + + IF ts_oSession != NIL + ts_oSession:End() + ts_oSession := NIL + ENDIF + +RETURN NIL + +FUNCTION fn_SessionActive() +RETURN ts_oSession != NIL + +FUNCTION fn_oSession( o ) + + IF o != NIL + ts_oSession := o + ENDIF + +RETURN ts_oSession + +FUNCTION fn_Garbage() + + IF ts_oSession != NIL + ts_oSession:Garbage() + ENDIF + +RETURN NIL + +// ── MH_Sessions class ── + +CREATE CLASS MH_Sessions + + VAR cName + VAR cId + VAR nExpired + VAR hData + VAR cSessionPath + VAR lDestroy + + METHOD New( cName, nExpired ) + METHOD Init() + METHOD SetId() + METHOD SessionFile() + METHOD Read_CSID() + METHOD Read() + METHOD Write() + METHOD Data( cKey, uValue ) + METHOD End() + METHOD Garbage( dMaxDate ) + METHOD IsFile() + METHOD Info() + +ENDCLASS + +METHOD New( cName, nExpired ) CLASS MH_Sessions + + ::cName := cName + ::nExpired := nExpired + ::hData := { => } + ::cId := "" + ::lDestroy := .F. + ::cSessionPath := ctx_get( "document_root", "." ) + "/.sessions" + + IF ! hb_DirExists( ::cSessionPath ) + hb_DirCreate( ::cSessionPath ) + ENDIF + +RETURN Self + +METHOD Init() CLASS MH_Sessions + + ::Read_CSID() + + IF Empty( ::cId ) + ::SetId() + fn_SetCookie( ::cName, ::cId, ::nExpired, "/", NIL, .F., .T. ) + ELSE + ::Read() + ENDIF + +RETURN NIL + +METHOD SetId() CLASS MH_Sessions + + ::cId := hb_MD5( hb_TToS( hb_DateTime() ) + hb_ntos( hb_RandomInt( 100000, 999999 ) ) ) + +RETURN ::cId + +METHOD SessionFile() CLASS MH_Sessions +RETURN ::cSessionPath + "/" + ::cId + ".session" + +METHOD Read_CSID() CLASS MH_Sessions + + LOCAL cCookieVal := fn_GetCookies( ::cName ) + + IF ! Empty( cCookieVal ) + ::cId := cCookieVal + ENDIF + +RETURN ::cId + +METHOD Read() CLASS MH_Sessions + + LOCAL cFile := ::SessionFile() + LOCAL cContent + + IF hb_FileExists( cFile ) + cContent := hb_MemoRead( cFile ) + IF ! Empty( cContent ) + hb_jsonDecode( cContent, @::hData ) + IF ::hData == NIL + ::hData := { => } + ENDIF + ENDIF + ENDIF + +RETURN NIL + +METHOD Write() CLASS MH_Sessions + + LOCAL cFile + + IF ::lDestroy + cFile := ::SessionFile() + IF hb_FileExists( cFile ) + FErase( cFile ) + ENDIF + RETURN NIL + ENDIF + + cFile := ::SessionFile() + hb_MemoWrit( cFile, hb_jsonEncode( ::hData ) ) + +RETURN NIL + +METHOD Data( cKey, uValue ) CLASS MH_Sessions + + IF cKey == NIL + RETURN ::hData + ENDIF + + IF uValue != NIL + ::hData[ cKey ] := uValue + RETURN uValue + ENDIF + + IF hb_HHasKey( ::hData, cKey ) + RETURN ::hData[ cKey ] + ENDIF + +RETURN NIL + +METHOD End() CLASS MH_Sessions + + ::lDestroy := .T. + ::Write() + fn_SetCookie( ::cName, "", 0, "/" ) + +RETURN NIL + +METHOD Garbage( dMaxDate ) CLASS MH_Sessions + + LOCAL aFiles, cFile + + hb_default( @dMaxDate, Date() - 1 ) + aFiles := Directory( ::cSessionPath + "/*.session" ) + + FOR EACH cFile IN aFiles + IF cFile[ 3 ] < dMaxDate + FErase( ::cSessionPath + "/" + cFile[ 1 ] ) + ENDIF + NEXT + +RETURN NIL + +METHOD IsFile() CLASS MH_Sessions +RETURN hb_FileExists( ::SessionFile() ) + +METHOD Info() CLASS MH_Sessions +RETURN { ; + "id" => ::cId, ; + "name" => ::cName, ; + "expired" => ::nExpired, ; + "path" => ::cSessionPath, ; + "file" => ::SessionFile(), ; + "data" => ::hData, ; + "destroy" => ::lDestroy ; +} + +// -- mod_harbour compatibility wrappers -- + +FUNCTION mh_SessionInit( cName, nExpired ) +RETURN fn_SessionInit( cName, nExpired ) + +FUNCTION mh_Session( cKey, uValue ) +RETURN fn_Session( cKey, uValue ) + +FUNCTION mh_SessionWrite() +RETURN fn_SessionWrite() + +FUNCTION mh_SessionEnd() +RETURN fn_SessionEnd() + +FUNCTION mh_SessionActive() +RETURN fn_SessionActive() + +FUNCTION mh_oSession( o ) +RETURN fn_oSession( o ) + +FUNCTION mh_Garbage() +RETURN fn_Garbage() diff --git a/app/bridge_server.prg b/app/bridge_server.prg new file mode 100644 index 0000000..fc8f3bb --- /dev/null +++ b/app/bridge_server.prg @@ -0,0 +1,91 @@ +// app/bridge_server.prg — 1a.3-3 end-to-end glue. +// +// Wires httpserver (Go) → bridge_capi (Go) → bridge_*.prg so a PRG +// handler written in the mod_harbour AP_* style runs inside the +// fivenode_go single binary exactly as it would have run inside the +// koffi/N-API fivenode bridge. +// +// Demo routing is hard-coded to two paths so we can curl the binary +// and see the AP_* surface working end-to-end. File-name dispatch +// (POST /api/foo.prg → app/api/foo.prg) is sub-phase 1a.4 work. + +FUNCTION Main() + LOCAL cErr := HTTP_SERVER_START( ":8090", "BRIDGEDISPATCH" ) + IF cErr != NIL + ? "httpserver:", cErr + ENDIF +RETURN NIL + +// Entry point invoked by httpserver. hReq comes from +// buildRequestHash in hbrtl_ext/httpserver/server.go. +FUNCTION BridgeDispatch( hReq ) + LOCAL hCtx + + // Translate the Go-side request hash into the ctx fields the + // mod_harbour AP_* PRG layer expects to read. + hCtx := { ; + "method" => hReq[ "method" ], ; + "filename" => hReq[ "path" ], ; + "query_string" => hReq[ "query" ], ; + "body" => hReq[ "body" ], ; + "remote_ip" => RemoteIPOnly( hReq[ "remote_addr" ] ), ; + "headers_in" => hReq[ "headers" ], ; + "headers_out" => { => }, ; + "status" => 200 ; + } + _CTX_SET_JSON( hb_jsonEncode( hCtx ) ) + _OUT_CLEAR() + + // Hard-coded route dispatch. Replace with file-name dispatch in + // 1a.4 once we want to land the labdb API surface unchanged. + DO CASE + CASE hReq[ "path" ] == "/api/hello" + ApiHello() + CASE hReq[ "path" ] == "/api/echo" + ApiEcho() + OTHERWISE + ctx_set( "status", 404 ) + AP_JSONRESPONSE( { "error" => "not found", "path" => hReq[ "path" ] } ) + ENDCASE + + // Assemble hResp from the buffered AP_* output. + RETURN { ; + "status" => ctx_get( "status", 200 ), ; + "headers" => ctx_get( "headers_out", { => } ), ; + "body" => _OUT_GET() ; + } + +FUNCTION ApiHello() + AP_JSONRESPONSE( { ; + "ok" => .t., ; + "msg" => "hello from fivenode_go bridge layer", ; + "method" => AP_METHOD(), ; + "ip" => AP_USERIP() ; + } ) +RETURN NIL + +FUNCTION ApiEcho() + LOCAL cBody := AP_BODY() + LOCAL hPayload := IIF( Empty( cBody ), { => }, hb_jsonDecode( cBody ) ) + AP_JSONRESPONSE( { ; + "ok" => .t., ; + "method" => AP_METHOD(), ; + "query" => AP_ARGS(), ; + "body_len" => Len( cBody ), ; + "body_parsed" => hPayload, ; + "user_agent" => hb_HGetDef( ctx_get( "headers_in", { => } ), "user-agent", "?" ) ; + } ) +RETURN NIL + +// Strip the ":port" portion of a Go RemoteAddr so AP_USERIP returns +// the bare IP the way mod_harbour does. +FUNCTION RemoteIPOnly( cAddr ) + LOCAL n + IF Empty( cAddr ) + RETURN "" + ENDIF + n := RAt( ":", cAddr ) + IF n > 0 + RETURN Left( cAddr, n - 1 ) + ENDIF +RETURN cAddr