add openapi interface and interface tests generation

This commit is contained in:
timurgordon
2025-01-07 00:40:09 -05:00
parent be1cee5d6a
commit bc9fd08f7e
15 changed files with 457 additions and 124 deletions

View File

@@ -2,9 +2,7 @@ module generator
import freeflowuniverse.herolib.core.code { Folder, IFile, VFile, CodeItem, File, Function, Import, Module, Struct, CustomCode }
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.schemas.openrpc
import freeflowuniverse.herolib.baobab.specification {ActorMethod, ActorSpecification, ActorInterface}
import os
import json
@[params]
@@ -19,6 +17,7 @@ pub fn generate_actor_module(spec ActorSpecification, params Params) !Module {
files = [
generate_readme_file(spec)!,
generate_actor_file(spec)!,
generate_specs_file(spec.name, params.interfaces)!,
generate_actor_test_file(spec)!,
generate_handle_file(spec)!,
generate_methods_file(spec)!
@@ -38,12 +37,31 @@ pub fn generate_actor_module(spec ActorSpecification, params Params) !Module {
// generate openrpc code files
// files << generate_openrpc_client_file(openrpc_spec)!
// files << generate_openrpc_client_test_file(openrpc_spec)!
files << generate_http_interface_file()!
files << generate_openrpc_interface_file()!
iface_file, iface_test_file := generate_openrpc_interface_files()
files << iface_file
files << iface_test_file
// add openrpc.json to docs
docs_files << generate_openrpc_file(openrpc_spec)!
}
}
.openapi {
// convert actor spec to openrpc spec
openapi_spec := spec.to_openapi()
// generate openrpc code files
iface_file, iface_test_file := generate_openapi_interface_files()
files << iface_file
files << iface_test_file
// add openrpc.json to docs
docs_files << generate_openapi_file(openapi_spec)!
}
.http {
// generate openrpc code files
iface_file, iface_test_file := generate_http_interface_files()
files << iface_file
files << iface_test_file
}
.command {
files << generate_command_file(spec)!
}
@@ -53,6 +71,7 @@ pub fn generate_actor_module(spec ActorSpecification, params Params) !Module {
}
}
// folder with docs
docs_folder := Folder {
name: 'docs'
@@ -64,7 +83,10 @@ pub fn generate_actor_module(spec ActorSpecification, params Params) !Module {
return code.new_module(
name: '${name_fixed}_actor'
files: files
folders: [docs_folder]
folders: [
docs_folder,
generate_scripts_folder()
]
)
}
@@ -98,13 +120,15 @@ fn generate_actor_test_file(spec ActorSpecification) !VFile {
}
}
pub fn generate_openapi_file(spec ActorSpecification) !File {
openapi_spec := spec.to_openapi()
openapi_json := json.encode(openapi_spec)
return File{
name: 'openapi'
extension: 'json'
content: openapi_json
fn generate_specs_file(name string, interfaces []ActorInterface) !VFile {
support_openrpc := ActorInterface.openrpc in interfaces
support_openapi := ActorInterface.openapi in interfaces
dollar := '$'
actor_name_snake := texttools.name_fix_snake(name)
actor_name_pascal := texttools.name_fix_snake_to_pascal(name)
actor_code := $tmpl('./templates/specifications.v.template')
return VFile {
name: 'specifications'
items: [CustomCode{actor_code}]
}
}
}

View File

@@ -2,7 +2,6 @@ module generator
import freeflowuniverse.herolib.core.code
import freeflowuniverse.herolib.baobab.specification
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.schemas.openrpc
import freeflowuniverse.herolib.schemas.jsonschema
import os
@@ -240,3 +239,29 @@ fn test_generate_actor_module_with_openrpc_interface() {
test: true
)!
}
fn test_generate_actor_module_with_openapi_interface() {
// plain actor module without interfaces
actor_module := generate_actor_module(actor_spec,
interfaces: [.openapi]
)!
actor_module.write(destination,
format: true
overwrite: true
compile: true
test: true
)!
}
fn test_generate_actor_module_with_all_interfaces() {
// plain actor module without interfaces
actor_module := generate_actor_module(actor_spec,
interfaces: [.openapi, .openrpc, .http]
)!
actor_module.write(destination,
format: true
overwrite: true
compile: true
test: true
)!
}

View File

@@ -5,8 +5,6 @@ import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.schemas.openrpc {ContentDescriptor}
import freeflowuniverse.herolib.schemas.jsonschema.codegen {schemaref_to_type}
import freeflowuniverse.herolib.baobab.specification {ActorMethod, ActorSpecification}
import os
import json
fn generate_handle_file(spec ActorSpecification) !VFile {
mut items := []CodeItem{}

View File

@@ -1,22 +1,39 @@
module generator
import freeflowuniverse.herolib.core.code { Folder, IFile, VFile, CodeItem, File, Function, Import, Module, Struct, CustomCode }
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.schemas.openrpc
import freeflowuniverse.herolib.baobab.specification {ActorMethod, ActorSpecification}
import os
import json
fn generate_openrpc_interface_file() !VFile {
return VFile {
fn generate_openrpc_interface_files() (VFile, VFile) {
iface_file := VFile {
name: 'interface_openrpc'
items: [CustomCode{$tmpl('./templates/interface_openrpc.v.template')}]
}
iface_test_file := VFile {
name: 'interface_openrpc_test'
items: [CustomCode{$tmpl('./templates/interface_openrpc_test.v.template')}]
}
return iface_file, iface_test_file
}
fn generate_http_interface_file() !VFile {
return VFile {
fn generate_openapi_interface_files() (VFile, VFile) {
iface_file := VFile {
name: 'interface_openapi'
items: [CustomCode{$tmpl('./templates/interface_openapi.v.template')}]
}
iface_test_file := VFile {
name: 'interface_openapi_test'
items: [CustomCode{$tmpl('./templates/interface_openapi_test.v.template')}]
}
return iface_file, iface_test_file
}
fn generate_http_interface_files() (VFile, VFile) {
iface_file := VFile {
name: 'interface_http'
items: [CustomCode{$tmpl('./templates/interface_http.v.template')}]
}
iface_test_file := VFile {
name: 'interface_http_test'
items: [CustomCode{$tmpl('./templates/interface_http_test.v.template')}]
}
return iface_file, iface_test_file
}

View File

@@ -0,0 +1,15 @@
module generator
import json
import freeflowuniverse.herolib.core.code { VFile, File, Function, Module, Struct }
import freeflowuniverse.herolib.schemas.openapi { Components, OpenAPI }
// import freeflowuniverse.herolib.schemas.openapi.codegen { generate_client_file, generate_client_test_file }
pub fn generate_openapi_file(specification OpenAPI) !File {
openapi_json := specification.encode_json()
return File{
name: 'openapi'
extension: 'json'
content: openapi_json
}
}

View File

@@ -0,0 +1,7 @@
fn test_new_http_server() ! {
new_http_server()!
}
fn test_run_http_server() ! {
spawn run_http_server()
}

View File

@@ -0,0 +1,17 @@
import freeflowuniverse.herolib.baobab.stage.interfaces
import freeflowuniverse.herolib.schemas.openapi
pub fn new_openapi_interface() !&interfaces.OpenAPIInterface {
// create OpenAPI Handler with actor's client
client := new_client()!
return interfaces.new_openapi_interface(client.Client)
}
// creates HTTP controller with the actor's OpenAPI Handler
// and OpenAPI Specification
pub fn new_openapi_http_controller() !&openapi.HTTPController {
return openapi.new_http_controller(
specification: openapi_specification
handler: new_openapi_interface()!
)
}

View File

@@ -0,0 +1,7 @@
fn test_new_openapi_interface() ! {
new_openapi_interface()!
}
fn test_new_openapi_http_controller() ! {
new_openapi_http_controller()!
}

View File

@@ -1,9 +1,6 @@
import freeflowuniverse.herolib.baobab.stage.interfaces
import freeflowuniverse.herolib.schemas.openrpc
const specification_path = os.join_path(os.dir(@@FILE), '/testdata/openrpc.json')
const specification = openrpc.new(path: specification_path)!
pub fn new_openrpc_interface() !&interfaces.OpenRPCInterface {
// create OpenRPC Handler with actor's client
client := new_client()!
@@ -14,7 +11,7 @@ pub fn new_openrpc_interface() !&interfaces.OpenRPCInterface {
// and OpenRPC Specification
pub fn new_openrpc_http_controller() !&openrpc.HTTPController {
return openrpc.new_http_controller(
specification: specification
specification: openrpc_specification
handler: new_openrpc_interface()!
)
}

View File

@@ -0,0 +1,7 @@
fn test_new_openrpc_interface() ! {
new_openrpc_interface()!
}
fn test_new_openrpc_http_controller() ! {
new_openrpc_http_controller()!
}

View File

@@ -0,0 +1,14 @@
import freeflowuniverse.herolib.schemas.openapi
import freeflowuniverse.herolib.schemas.openrpc
import os
@if support_openrpc
const openrpc_spec_path = '@{dollar}{os.dir(@@FILE)}/docs/openrpc.json'
const openrpc_spec_json = os.read_file(openrpc_spec_path) or { panic(err) }
const openrpc_specification = openrpc.decode(openrpc_spec_json)!
@end
@if support_openapi
const openapi_spec_path = '@{dollar}{os.dir(@@FILE)}/docs/openapi.json'
const openapi_spec_json = os.read_file(openapi_spec_path) or { panic(err) }
const openapi_specification = openapi.json_decode(openapi_spec_json)!
@end

View File

@@ -18,6 +18,7 @@ pub enum ActorInterface {
openapi
webui
command
http
}
pub struct ActorMethod {

View File

@@ -1,77 +1,48 @@
module interfaces
import veb
import freeflowuniverse.herolib.schemas.openapi { Context, Controller, OpenAPI, Request, Response }
import os
import time
import json
import x.json2
import net.http
import freeflowuniverse.herolib.schemas.jsonschema
import freeflowuniverse.herolib.core.redisclient
import rand
import x.json2 as json {Any}
import freeflowuniverse.herolib.baobab.stage {Action, Client}
import freeflowuniverse.herolib.schemas.jsonrpc
import freeflowuniverse.herolib.schemas.openapi
// pub struct OpenAPIProxy {
// pub:
// client Client
// specification OpenAPI
// }
pub struct OpenAPIInterface {
pub mut:
client Client
}
// // creates and OpenAPI Proxy Controller
// pub fn new_openapi_proxy(proxy OpenAPIProxy) OpenAPIProxy {
// return proxy
// }
pub fn new_openapi_interface(client Client) &OpenAPIInterface {
return &OpenAPIInterface{client}
}
// // creates and OpenAPI Proxy Controller
// pub fn (proxy OpenAPIProxy) controller() &Controller {
// // Initialize the server
// mut controller := &Controller{
// specification: proxy.specification
// handler: Handler{
// client: proxy.client
// }
// }
// return controller
// }
pub fn (mut i OpenAPIInterface) handle(request openapi.Request) !openapi.Response {
// Convert incoming OpenAPI request to a procedure call
action := action_from_openapi_request(request)
response := i.client.call_to_action(action)!
return action_to_openapi_response(response)
}
// @[params]
// pub struct RunParams {
// pub:
// port int = 8080
// }
pub fn action_from_openapi_request(request openapi.Request) Action {
mut params := []Any{}
if request.arguments.len > 0 {
params << request.arguments.values()
}
if request.body != '' {
params << request.body
}
if request.parameters.len > 0 {
params << json.encode(request.parameters)
}
return Action {
id: rand.uuid_v4()
name: request.operation.operation_id
params: json.encode(params.str())
}
}
// fn (proxy OpenAPIProxy) run(params RunParams) {
// mut controller := proxy.controller()
// veb.run[Controller, Context](mut controller, params.port)
// }
// pub struct Handler {
// pub mut:
// client Client
// }
// fn (mut handler Handler) handle(request Request) !Response {
// // Convert incoming OpenAPI request to a procedure call
// call := rpc.openapi_request_to_procedure_call(request)
// // Process the procedure call
// procedure_response := handler.client.dialogue(call, Params{
// timeout: 30 // Set timeout in seconds
// }) or {
// // Handle ProcedureError
// if err is ProcedureError {
// return Response{
// status: http.status_from_int(err.code()) // Map ProcedureError reason to HTTP status code
// body: json.encode({
// 'error': err.msg()
// })
// }
// }
// return error('Unexpected error: ${err}')
// }
// // Convert returned procedure response to OpenAPI response
// return Response{
// status: http.Status.ok // Assuming success if no error
// body: procedure_response.result
// }
// }
pub fn action_to_openapi_response(action Action) openapi.Response {
return openapi.Response {
body: action.result
}
}

View File

@@ -0,0 +1,220 @@
module openapi
import veb
import freeflowuniverse.herolib.schemas.jsonschema {Schema}
import x.json2 {Any}
import net.http
pub struct HTTPController {
Handler // Handles OpenAPI requests
pub:
specification OpenAPI
}
pub struct Context {
veb.Context
}
// Creates a new HTTPController instance
pub fn new_http_controller(c HTTPController) &HTTPController {
return &HTTPController{
...c,
Handler: c.Handler
}
}
@['/:path...'; get; post; put; delete; patch]
pub fn (mut c HTTPController) index(mut ctx Context, path string) veb.Result {
println('Requested path: $path')
// Extract the HTTP method
method := ctx.req.method.str().to_lower()
// Matches the request path against the OpenAPI specification and retrieves the corresponding PathItem
path_item := match_path(path, c.specification) or {
// Return a 404 error if no matching path is found
return ctx.not_found()
}
// // Check if the path exists in the OpenAPI specification
// path_item := c.specification.paths[path] or {
// // Return a 404 error if the path is not defined
// return ctx.not_found()
// }
// Match the HTTP method with the OpenAPI specification
operation := match method {
'get' { path_item.get }
'post' { path_item.post }
'put' { path_item.put }
'delete' { path_item.delete }
'patch' { path_item.patch }
else {
// Return 405 Method Not Allowed if the method is not supported
return ctx.method_not_allowed()
}
}
mut arg_map := map[string]Any
path_arg := path.all_after_last('/')
// the OpenAPI Parameter specification belonging to the path argument
arg_params := operation.parameters.filter(it.in_ == 'path')
if arg_params.len > 1 {
// TODO: use path template to support multiple arguments (right now just last arg supported)
panic('implement')
} else if arg_params.len == 1 {
arg_map[arg_params[0].name] = arg_params[0].typed(path_arg)
}
mut parameters := ctx.query.clone()
// Build the Request object
request := Request{
path: path
operation: operation
method: method
arguments: arg_map
parameters: parameters
body: ctx.req.data
header: ctx.req.header
}
// Use the handler to process the request
response := c.handler.handle(request) or {
// Use OpenAPI spec to determine the response status for the error
return ctx.handle_error(operation.responses, err)
}
// Return the response to the client
ctx.res.set_status(response.status)
// ctx.res.header = response.header
// ctx.set_content_type('application/json')
// return ctx.ok('[]')
return ctx.send_response_to_client('application/json', response.body)
}
// Handles errors and maps them to OpenAPI-defined response statuses
fn (mut ctx Context) handle_error(possible_responses map[string]ResponseSpec, err IError) veb.Result {
// Match the error with the defined responses
for code, _ in possible_responses {
if matches_error_to_status(err, code.int()) {
ctx.res.set_status(http.status_from_int(code.int()))
ctx.set_content_type('application/json')
return ctx.send_response_to_client(
'application/json',
'{"error": "$err.msg()", "status": $code}'
)
}
}
// Default to 500 Internal HTTPController Error if no match is found
return ctx.server_error(
'{"error": "Internal HTTPController Error", "status": 500}'
)
}
// Helper for 405 Method Not Allowed response
fn (mut ctx Context) method_not_allowed() veb.Result {
ctx.res.set_status(.method_not_allowed)
ctx.set_content_type('application/json')
return ctx.send_response_to_client(
'application/json',
'{"error": "Method Not Allowed", "status": 405}'
)
}
// Matches a request path against OpenAPI path templates in the parsed structs
// Returns the matching path key and corresponding PathItem if found
fn match_path(req_path string, spec OpenAPI) !PathItem {
// Iterate through all paths in the OpenAPI specification
for template, path_item in spec.paths {
if is_path_match(req_path, template) {
// Return the matching path template and its PathItem
return path_item
}
}
// If no match is found, return an error
return error('Path not found')
}
// Helper to match an error to a specific response status
fn matches_error_to_status(err IError, status int) bool {
// This can be customized to map specific errors to statuses
// For simplicity, we'll use a direct comparison here.
return err.code() == status
}
// Checks if a request path matches a given OpenAPI path template
// Allows for dynamic path segments like `{petId}` in templates
fn is_path_match(req_path string, template string) bool {
// Split the request path and template into segments
req_segments := req_path.split('/')
template_segments := template.split('/')
// If the number of segments doesn't match, the paths can't match
if req_segments.len != template_segments.len {
return false
}
// Compare each segment in the template and request path
for i, segment in template_segments {
// If the segment is not dynamic (doesn't start with `{`), ensure it matches exactly
if !segment.starts_with('{') && segment != req_segments[i] {
return false
}
}
// If all segments match or dynamic segments are valid, return true
return true
}
pub fn (param Parameter) typed(value string) Any {
param_schema := param.schema as Schema
param_type := param_schema.typ
param_format := param_schema.format
// Convert parameter value to corresponding type
typ := match param_type {
'integer' {
param_format
}
'number' {
param_format
}
else {
param_type // Leave as param type for unknown types
}
}
return typed(value, typ)
}
// typed gets a value that is string and a desired type, and returns the typed string in Any Type.
pub fn typed(value string, typ string) Any {
match typ {
'int32' {
return value.int() // Convert to int
}
'int64' {
return value.i64() // Convert to i64
}
'string' {
return value // Already a string
}
'boolean' {
return value.bool() // Convert to bool
}
'float' {
return value.f32() // Convert to float
}
'double' {
return value.f64() // Convert to double
}
else {
return value.f64() // Leave as string for unknown types
}
}
}

View File

@@ -2,6 +2,7 @@ module openapi
import net.http {CommonHeader}
import x.json2 {Any}
import freeflowuniverse.herolib.schemas.jsonrpc
pub struct Request {
pub:
@@ -22,32 +23,44 @@ pub mut:
header http.Header @[omitempty; str: skip; json:'-']// Response headers
}
pub interface IHandler {
mut:
handle(Request) !Response
}
pub struct Handler {
pub:
routes map[string]fn (Request) !Response // Map of route handlers
specification OpenAPI @[required] // The OpenRPC specification
pub mut:
handler IHandler
}
// Handle a request and return a response
pub fn (handler Handler) handle(request Request) !Response {
// Match the route based on the request path
if route_handler := handler.routes[request.path] {
// Call the corresponding route handler
return route_handler(request)
}
// Return 404 if no route matches
return Response{
status: .not_found
body: 'Not Found'
header: http.new_header(
key: CommonHeader.content_type,
value: 'text/plain'
)
}
pub interface IHandler {
mut:
handle(Request) !Response // Custom handler for other methods
}
@[params]
pub struct HandleParams {
timeout int = 60 // Timeout in seconds
retry int // Number of retries
}
// Handle a JSON-RPC request and return a response
pub fn (mut h Handler) handle(req Request, params HandleParams) !Response {
// Validate the method exists in the specification
// if req.method !in h.specification.methods.map(it.name) {
// // Return 404 if no route matches
// return Response{
// status: .not_found
// body: 'Not Found'
// header: http.new_header(
// key: CommonHeader.content_type,
// value: 'text/plain'
// )
// }
// }
// Enforce timeout and retries (dummy implementation)
if params.timeout < 0 || params.retry < 0 {
panic('implement')
}
// Forward the request to the custom handler
return h.handler.handle(req)
}