From f54c57847a4adaec1a12005c6ef7fecf365929de Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Thu, 18 Sep 2025 17:29:11 +0300 Subject: [PATCH] feat: Enhance logging and CORS handling - Add console output option to logger - Implement ISO time conversion for calendar events - Add OPTIONS method for API and root handlers - Introduce health check endpoint with uptime and server info - Implement manual CORS handling in `before_request` - Add `start_time` to HeroServer for uptime tracking - Add `ServerLogParams` and `log` method for server logging --- lib/core/logger/factory.v | 8 +- lib/core/logger/log.v | 22 +++ lib/core/logger/model.v | 5 +- lib/hero/heromodels/rpc/rpc_calendar_event.v | 41 +++++- lib/hero/heroserver/api_handler.v | 40 +++++- lib/hero/heroserver/doc_handler.v | 141 ++++++++++++++++++- lib/hero/heroserver/factory.v | 17 ++- lib/hero/heroserver/model.v | 52 ++++++- 8 files changed, 300 insertions(+), 26 deletions(-) diff --git a/lib/core/logger/factory.v b/lib/core/logger/factory.v index dc12ff3c..bc3e35c1 100644 --- a/lib/core/logger/factory.v +++ b/lib/core/logger/factory.v @@ -5,13 +5,15 @@ import freeflowuniverse.herolib.core.pathlib // Logger Factory pub struct LoggerFactoryArgs { pub mut: - path string + path string + console_output bool = true } pub fn new(args LoggerFactoryArgs) !Logger { mut p := pathlib.get_dir(path: args.path, create: true)! return Logger{ - path: p - lastlog_time: 0 + path: p + lastlog_time: 0 + console_output: args.console_output } } diff --git a/lib/core/logger/log.v b/lib/core/logger/log.v index 4d5049a1..2f70ffeb 100644 --- a/lib/core/logger/log.v +++ b/lib/core/logger/log.v @@ -61,4 +61,26 @@ pub fn (mut l Logger) log(args_ LogItemArgs) ! { } f.writeln(content.trim_space_right())! f.close() + + // Also write to console if enabled + if l.console_output { + l.write_to_console(args, t)! + } +} + +// Write log message to console with clean formatting +fn (mut l Logger) write_to_console(args LogItemArgs, t ourtime.OurTime) ! { + timestamp := t.time().format_ss() + error_indicator := if args.logtype == .error { 'ERROR' } else { 'INFO' } + category := args.cat.trim_space() + lines := args.log.split('\n') + + for i, line in lines { + if i == 0 { + println('${timestamp} [${error_indicator}] [${category}] ${line}') + } else { + // Indent continuation lines + println('${timestamp} [${error_indicator}] [${category}] ${line}') + } + } } diff --git a/lib/core/logger/model.v b/lib/core/logger/model.v index 1a04ce19..7a5ff0c7 100644 --- a/lib/core/logger/model.v +++ b/lib/core/logger/model.v @@ -6,8 +6,9 @@ import freeflowuniverse.herolib.core.pathlib @[heap] pub struct Logger { pub mut: - path pathlib.Path - lastlog_time i64 // to see in log format, every second we put a time down, we need to know if we are in a new second (logs can come in much faster) + path pathlib.Path + lastlog_time i64 // to see in log format, every second we put a time down, we need to know if we are in a new second (logs can come in much faster) + console_output bool // whether to also output to console/stdout } pub struct LogItem { diff --git a/lib/hero/heromodels/rpc/rpc_calendar_event.v b/lib/hero/heromodels/rpc/rpc_calendar_event.v index 3f0cef27..7a993835 100644 --- a/lib/hero/heromodels/rpc/rpc_calendar_event.v +++ b/lib/hero/heromodels/rpc/rpc_calendar_event.v @@ -4,6 +4,26 @@ import json import freeflowuniverse.herolib.schemas.jsonrpc { Request, Response, new_response_true, new_response_u32 } import freeflowuniverse.herolib.hero.heromodels import freeflowuniverse.herolib.hero.db + +fn convert_iso_to_ourtime(iso_time string) !string { + if iso_time.trim_space() == '' { + return error('Empty time string') + } + + // Remove the 'Z' suffix if present + mut cleaned := iso_time.replace('Z', '') + + // Remove milliseconds (.000) if present + if cleaned.contains('.') { + cleaned = cleaned.all_before_last('.') + } + + // Replace 'T' with space + cleaned = cleaned.replace('T', ' ') + + return cleaned +} + // CalendarEvent-specific argument structures @[params] @@ -58,13 +78,20 @@ pub fn calendar_event_set(request Request) !Response { return jsonrpc.invalid_params } - mut mydb := heromodels.new()! + mut mydb := heromodels.new() or { return jsonrpc.internal_error } + start_time_converted := convert_iso_to_ourtime(payload.start_time) or { + return jsonrpc.internal_error + } + end_time_converted := convert_iso_to_ourtime(payload.end_time) or { + return jsonrpc.internal_error + } + mut calendar_event_obj := mydb.calendar_event.new( name: payload.name description: payload.description title: payload.title - start_time: payload.start_time - end_time: payload.end_time + start_time: start_time_converted + end_time: end_time_converted location: payload.location attendees: payload.attendees fs_items: payload.fs_items @@ -79,9 +106,11 @@ pub fn calendar_event_set(request Request) !Response { securitypolicy: payload.securitypolicy tags: payload.tags comments: payload.comments - )! + ) or { return jsonrpc.internal_error } - calendar_event_obj = mydb.calendar_event.set(calendar_event_obj)! + calendar_event_obj = mydb.calendar_event.set(calendar_event_obj) or { + return jsonrpc.internal_error + } return new_response_u32(request.id, calendar_event_obj.id) } @@ -97,9 +126,9 @@ pub fn calendar_event_delete(request Request) !Response { return new_response_true(request.id) // return true as jsonrpc (bool) } +// List all calendar events pub fn calendar_event_list(request Request) !Response { mut mydb := heromodels.new()! calendar_events := mydb.calendar_event.list()! - return jsonrpc.new_response(request.id, json.encode(calendar_events)) } diff --git a/lib/hero/heroserver/api_handler.v b/lib/hero/heroserver/api_handler.v index 1b6c926d..3cacf714 100644 --- a/lib/hero/heroserver/api_handler.v +++ b/lib/hero/heroserver/api_handler.v @@ -1,6 +1,7 @@ module heroserver import json +import net.http import veb import freeflowuniverse.herolib.schemas.jsonrpc @@ -36,8 +37,41 @@ pub fn (mut server HeroServer) auth_handler(mut ctx Context, action string) !veb } } -@['/api/:handler_type'; post] +@['/api/:handler_type'; options; post] pub fn (mut server HeroServer) api_handler(mut ctx Context, handler_type string) veb.Result { + if ctx.req.method == http.Method.options { + if server.cors_enabled { + origin := ctx.get_header(.origin) or { '' } + if origin != '' + && (server.allowed_origins.contains('*') || server.allowed_origins.contains(origin)) { + ctx.set_header(.access_control_allow_origin, origin) + ctx.set_header(.access_control_allow_methods, 'GET, HEAD, PATCH, PUT, POST, DELETE, OPTIONS') + ctx.set_header(.access_control_allow_headers, 'Content-Type, Authorization, X-Requested-With') + ctx.set_header(.access_control_allow_credentials, 'true') + ctx.set_header(.vary, 'Origin') + server.log( + message: 'CORS headers set for origin: ${origin}' + ) + } + } + + return ctx.text('') + } + + // Set CORS headers for POST response + if server.cors_enabled { + origin := ctx.get_header(.origin) or { '' } + if origin != '' + && (server.allowed_origins.contains('*') || server.allowed_origins.contains(origin)) { + ctx.set_header(.access_control_allow_origin, origin) + ctx.set_header(.access_control_allow_credentials, 'true') + ctx.set_header(.vary, 'Origin') + server.log( + message: 'CORS headers set for API POST response, origin: ${origin}' + ) + } + } + // TODO: For now, skip authentication for testing // session_key := ctx.get_header(.authorization) or { // return ctx.request_error('Missing session key in Authorization header') @@ -61,6 +95,6 @@ pub fn (mut server HeroServer) api_handler(mut ctx Context, handler_type string) // Handle the request using the OpenRPC handler response := handler.handle(request) or { return ctx.server_error('Handler error: ${err}') } - // Return the JSON-RPC response - return ctx.json(response) + ctx.set_header(.content_type, 'application/json') + return ctx.text(response.encode()) } diff --git a/lib/hero/heroserver/doc_handler.v b/lib/hero/heroserver/doc_handler.v index e5fb10a8..1f20c3ee 100644 --- a/lib/hero/heroserver/doc_handler.v +++ b/lib/hero/heroserver/doc_handler.v @@ -1,12 +1,113 @@ module heroserver import veb +import net.http import freeflowuniverse.herolib.schemas.openrpc import freeflowuniverse.herolib.schemas.jsonschema +import freeflowuniverse.herolib.schemas.jsonrpc +import time -// Home page handler - returns HTML homepage -@['/'] +// Home page handler - returns HTML homepage for GET, handles JSON-RPC for POST +@['/'; get; options; post] pub fn (mut server HeroServer) home_handler(mut ctx Context) veb.Result { + server.log( + message: 'New request: ${ctx.req.method} /' + ) + + // Handle CORS preflight OPTIONS request + if ctx.req.method == http.Method.options { + server.log( + message: 'Handling OPTIONS preflight request for root' + ) + + // Ensure CORS headers are set for OPTIONS response + if server.cors_enabled { + origin := ctx.get_header(.origin) or { '' } + if origin != '' + && (server.allowed_origins.contains('*') || server.allowed_origins.contains(origin)) { + ctx.set_header(.access_control_allow_origin, origin) + ctx.set_header(.access_control_allow_methods, 'GET, HEAD, PATCH, PUT, POST, DELETE, OPTIONS') + ctx.set_header(.access_control_allow_headers, 'Content-Type, Authorization, X-Requested-With') + ctx.set_header(.access_control_allow_credentials, 'true') + ctx.set_header(.vary, 'Origin') + server.log( + message: 'CORS headers set for origin: ${origin}' + ) + } + } + + return ctx.text('') + } + + // Handle POST requests as JSON-RPC + if ctx.req.method == http.Method.post { + server.log( + message: 'Handling JSON-RPC request at root endpoint' + ) + + // Set CORS headers for POST response + if server.cors_enabled { + origin := ctx.get_header(.origin) or { '' } + if origin != '' + && (server.allowed_origins.contains('*') || server.allowed_origins.contains(origin)) { + ctx.set_header(.access_control_allow_origin, origin) + ctx.set_header(.access_control_allow_credentials, 'true') + ctx.set_header(.vary, 'Origin') + server.log( + message: 'CORS headers set for POST response, origin: ${origin}' + ) + } + } + + // Check if we have handlers + if server.handlers.len == 0 { + return ctx.server_error('No handlers registered') + } + + // Use the first registered handler for root requests + handler_name := server.handlers.keys()[0] + server.log( + message: 'Using handler: ${handler_name}' + ) + + mut handler := server.handlers[handler_name] or { + return ctx.request_error('Handler not found: ${handler_name}') + } + + // Parse JSON-RPC request + request := jsonrpc.decode_request(ctx.req.data) or { + server.log( + message: 'Invalid JSON-RPC request: ${err}' + level: .error + ) + return ctx.request_error('Invalid JSON-RPC request: ${err}') + } + + server.log( + message: 'JSON-RPC method: ${request.method}' + ) + + // Handle the request using the OpenRPC handler + response := handler.handle(request) or { + server.log( + message: 'Handler error: ${err}' + level: .error + ) + return ctx.server_error('Handler error: ${err}') + } + + server.log( + message: 'JSON-RPC response sent' + ) + ctx.set_header(.content_type, 'application/json') + return ctx.text(response.encode()) + } + + // Handle GET requests as HTML homepage + server.log( + message: 'Serving HTML homepage' + ) + // Create a simple server info structure for the template server_info := HomePageData{ base_url: get_base_url_from_context(ctx) @@ -22,6 +123,42 @@ pub fn (mut server HeroServer) home_handler(mut ctx Context) veb.Result { return ctx.html(html_content) } +// Health check endpoint +@['/health'; get] +pub fn (mut server HeroServer) health_handler(mut ctx Context) veb.Result { + server.log( + message: 'Health check requested' + ) + + // Create health status response + current_time := time.now().unix() + uptime := current_time - server.start_time + + health_status := { + 'status': 'healthy' + 'timestamp': current_time.str() + 'version': '1.0.0' + 'handlers_count': server.handlers.len.str() + 'auth_enabled': server.auth_enabled.str() + 'cors_enabled': server.cors_enabled.str() + 'uptime_seconds': uptime.str() + } + + // Set CORS headers if enabled + if server.cors_enabled { + origin := ctx.get_header(.origin) or { '' } + if origin != '' + && (server.allowed_origins.contains('*') || server.allowed_origins.contains(origin)) { + ctx.set_header(.access_control_allow_origin, origin) + ctx.set_header(.access_control_allow_credentials, 'true') + ctx.set_header(.vary, 'Origin') + } + } + + ctx.set_header(.content_type, 'application/json') + return ctx.json(health_status) +} + // JSON server info handler @['/json/:handler_type'] pub fn (mut server HeroServer) json_handler(mut ctx Context, handler_type string) veb.Result { diff --git a/lib/hero/heroserver/factory.v b/lib/hero/heroserver/factory.v index c5250da3..704b5402 100644 --- a/lib/hero/heroserver/factory.v +++ b/lib/hero/heroserver/factory.v @@ -3,6 +3,8 @@ module heroserver import freeflowuniverse.herolib.crypt.herocrypt import freeflowuniverse.herolib.schemas.openrpc import freeflowuniverse.herolib.ui.console +import freeflowuniverse.herolib.core.logger +import time import veb // Create a new HeroServer instance @@ -14,6 +16,12 @@ pub fn new(config HeroServerConfig) !&HeroServer { herocrypt.new_default()! } + // Create logger with configurable output + mut server_logger := logger.new( + path: config.log_path + console_output: config.console_output + ) or { return error('Failed to create logger: ${err}') } + mut server := &HeroServer{ port: config.port host: config.host @@ -24,6 +32,8 @@ pub fn new(config HeroServerConfig) !&HeroServer { auth_enabled: config.auth_enabled cors_enabled: config.cors_enabled allowed_origins: config.allowed_origins.clone() + logger: server_logger + start_time: time.now().unix() } console.print_header('HeroServer created on port ${server.port}') @@ -38,15 +48,8 @@ pub fn (mut server HeroServer) register_handler(handler_type string, handler &op // Start the server pub fn (mut server HeroServer) start() ! { - // Configure CORS if enabled if server.cors_enabled { console.print_item('CORS enabled for origins: ${server.allowed_origins}') - server.use(veb.cors[Context](veb.CorsOptions{ - origins: server.allowed_origins - allowed_methods: [.get, .head, .patch, .put, .post, .delete, .options] - allowed_headers: ['Content-Type', 'Authorization', 'X-Requested-With'] - allow_credentials: true - })) } // Start VEB server diff --git a/lib/hero/heroserver/model.v b/lib/hero/heroserver/model.v index 3ae25983..61df90ae 100644 --- a/lib/hero/heroserver/model.v +++ b/lib/hero/heroserver/model.v @@ -2,6 +2,7 @@ module heroserver import freeflowuniverse.herolib.crypt.herocrypt import freeflowuniverse.herolib.schemas.openrpc +import freeflowuniverse.herolib.core.logger import time import veb @@ -9,9 +10,13 @@ import veb @[params] pub struct HeroServerConfig { pub mut: - port int = 9977 - host string = 'localhost' - auth_enabled bool = true // Whether to enable authentication + port int = 9977 + host string = 'localhost' + log_path string = '/tmp/heroserver_logs' + console_output bool = true // Enable console logging by default + + // flags + auth_enabled bool = true // Whether to enable authentication // CORS configuration cors_enabled bool = true // Whether to enable CORS allowed_origins []string = ['*'] // Allowed origins for CORS, default allows all @@ -31,10 +36,33 @@ mut: challenges map[string]AuthChallenge cors_enabled bool allowed_origins []string + logger logger.Logger // Logger instance with dual output + start_time i64 // Server start timestamp for uptime calculation pub mut: auth_enabled bool = true // Whether authentication is required } +// Convenient logging method for the server +@[params] +pub struct ServerLogParams { +pub: + message string + level logger.LogType = .stdout // Default to info level + cat string = 'server' // Default category +} + +// Log a message using the server's logger +pub fn (mut server HeroServer) log(params ServerLogParams) { + server.logger.log( + cat: params.cat + log: params.message + logtype: params.level + ) or { + // Fallback to console if logging fails + println('[${params.cat}] ${params.message}') + } +} + // Authentication challenge data pub struct AuthChallenge { pub mut: @@ -169,3 +197,21 @@ pub: pub struct Context { veb.Context } + +// before_request is called before every request +pub fn (mut server HeroServer) before_request(mut ctx Context) { + // Handle CORS manually + if server.cors_enabled { + origin := ctx.get_header(.origin) or { '' } + + // Check if origin is allowed + if origin != '' + && (server.allowed_origins.contains('*') || server.allowed_origins.contains(origin)) { + ctx.set_header(.access_control_allow_origin, origin) + ctx.set_header(.access_control_allow_methods, 'GET, HEAD, PATCH, PUT, POST, DELETE, OPTIONS') + ctx.set_header(.access_control_allow_headers, 'Content-Type, Authorization, X-Requested-With') + ctx.set_header(.access_control_allow_credentials, 'true') + ctx.set_header(.vary, 'Origin') + } + } +}