feat: generate dynamic API docs from OpenRPC spec

- Implement dynamic doc generation from OpenRPC methods
- Generate example calls and responses from schemas
- Improve OpenRPC and JSON Schema decoders for full parsing
- Add example value generation based on schema type
- Add tests for schema decoding with examples
This commit is contained in:
Mahmoud-Emad
2025-09-17 17:50:43 +03:00
parent 844e3d5214
commit e4101351aa
8 changed files with 299 additions and 57 deletions

View File

@@ -26,6 +26,43 @@
"type": "string"
}
}
},
{
"name": "get_comment",
"summary": "Get a comment by ID",
"description": "Retrieves a specific comment using its unique identifier.",
"params": [
{
"name": "id",
"description": "The unique identifier of the comment to retrieve.",
"required": true,
"schema": {
"type": "string",
"example": "comment_12345"
}
},
{
"name": "include_metadata",
"description": "Whether to include metadata in the response.",
"required": false,
"schema": {
"type": "boolean",
"example": true
}
}
],
"result": {
"name": "comment",
"description": "The requested comment object.",
"schema": {
"type": "object",
"example": {
"id": "comment_12345",
"text": "This is a sample comment",
"created_at": "2024-01-15T10:30:00Z"
}
}
}
}
],
"components": {}

View File

@@ -1,7 +1,6 @@
module heroserver
import veb
import os
@['/doc/:handler_type']
pub fn (mut server HeroServer) doc_handler(mut ctx Context, handler_type string) veb.Result {
@@ -9,7 +8,9 @@ pub fn (mut server HeroServer) doc_handler(mut ctx Context, handler_type string)
handler := server.handlers[handler_type] or { return ctx.not_found() }
// Convert the OpenRPC specification to a DocSpec
spec := doc_spec_from_openrpc(handler.specification, handler_type)
spec := doc_spec_from_openrpc(handler.specification, handler_type) or {
return ctx.server_error('Failed to generate documentation: ${err}')
}
// Load and process the HTML template using the literal path
html_content := $tmpl('templates/doc.html')

View File

@@ -1,14 +1,15 @@
module heroserver
import freeflowuniverse.herolib.schemas.openrpc
import freeflowuniverse.herolib.schemas.jsonschema
// DocSpec is the main object passed to the documentation template.
pub struct DocSpec {
pub mut:
info openrpc.Info
methods []DocMethod
objects []DocObject
auth_info AuthDocInfo
info openrpc.Info
methods []DocMethod
objects []DocObject
auth_info AuthDocInfo
}
// DocObject represents a logical grouping of methods.
@@ -58,65 +59,176 @@ pub mut:
example string
}
// Converts an OpenRPC spec to a documentation-friendly spec
pub fn doc_spec_from_openrpc(openrpc_spec openrpc.OpenRPC, handler_type string) DocSpec {
// doc_spec_from_openrpc converts an OpenRPC specification to a documentation-friendly DocSpec.
// Processes all methods, parameters, and results with proper type extraction and example generation.
// Returns error if handler_type is empty or if OpenRPC spec is invalid.
pub fn doc_spec_from_openrpc(openrpc_spec openrpc.OpenRPC, handler_type string) !DocSpec {
if handler_type.trim_space() == '' {
return error('handler_type cannot be empty')
}
mut doc_spec := DocSpec{
info: openrpc_spec.info
info: openrpc_spec.info
auth_info: create_auth_info()
}
// Simplified implementation for now
for method in openrpc_spec.methods {
// Convert parameters
mut doc_params := []DocParam{}
for param in method.params {
if param is openrpc.ContentDescriptor {
type_info := extract_type_from_schema(param.schema)
example := generate_example_from_schema(param.schema, param.name)
doc_param := DocParam{
name: param.name
description: param.description
required: param.required
type_info: type_info
example: example
}
doc_params << doc_param
}
}
// Convert result
mut doc_result := DocParam{}
if method.result is openrpc.ContentDescriptor {
result_cd := method.result as openrpc.ContentDescriptor
type_info := extract_type_from_schema(result_cd.schema)
example := generate_example_from_schema(result_cd.schema, result_cd.name)
doc_result = DocParam{
name: result_cd.name
description: result_cd.description
required: result_cd.required
type_info: type_info
example: example
}
}
// Generate example call and response
example_call := generate_example_call(doc_params)
example_response := generate_example_response(doc_result)
doc_method := DocMethod{
name: method.name
summary: method.summary
description: method.description
endpoint_url: '/api/${handler_type}/${method.name}'
example_call: '{}'
example_response: '{"result": "success"}'
name: method.name
summary: method.summary
description: method.description
params: doc_params
result: doc_result
endpoint_url: '/api/${handler_type}/${method.name}'
example_call: example_call
example_response: example_response
}
doc_spec.methods << doc_method
}
return doc_spec
}
// extract_type_from_schema extracts the JSON Schema type from a SchemaRef.
// Returns the type string (e.g., 'string', 'object', 'array') or 'reference'/'unknown' for edge cases.
fn extract_type_from_schema(schema_ref jsonschema.SchemaRef) string {
schema := match schema_ref {
jsonschema.Schema {
schema_ref
}
jsonschema.Reference {
return 'reference'
}
}
if schema.typ.len > 0 {
return schema.typ
}
return 'unknown'
}
// generate_example_from_schema creates an example value for a parameter or result.
// Uses the jsonschema.Schema.example_value() method with parameter name customization for strings.
// Returns properly formatted JSON values based on the schema type.
fn generate_example_from_schema(schema_ref jsonschema.SchemaRef, param_name string) string {
schema := match schema_ref {
jsonschema.Schema {
schema_ref
}
jsonschema.Reference {
return '"reference_value"'
}
}
// Use the improved example_value() method from jsonschema module
example := schema.example_value()
// For string types without explicit examples, customize with parameter name
if example == '"example_value"' && schema.typ == 'string' && param_name != '' {
return '"example_${param_name}"'
}
return example
}
// generate_example_call creates a formatted JSON example for method calls.
// Combines all parameter examples into a properly formatted JSON object.
fn generate_example_call(params []DocParam) string {
if params.len == 0 {
return '{}'
}
mut call_parts := []string{}
for param in params {
call_parts << '"${param.name}": ${param.example}'
}
return '{\n ${call_parts.join(',\n ')}\n}'
}
// generate_example_response creates a formatted JSON example for method responses.
// Wraps the result example in a standard {"result": ...} format.
fn generate_example_response(result DocParam) string {
if result.name == '' {
return '{"result": "success"}'
}
return '{"result": ${result.example}}'
}
// Create authentication documentation info
fn create_auth_info() AuthDocInfo {
return AuthDocInfo{
steps: [
AuthStep{
number: 1
title: 'Register Public Key'
method: 'POST'
endpoint: '/auth/register'
number: 1
title: 'Register Public Key'
method: 'POST'
endpoint: '/auth/register'
description: 'Register your public key with the server'
example: '{\n "pubkey": "your_public_key_here"\n}'
example: '{\n "pubkey": "your_public_key_here"\n}'
},
AuthStep{
number: 2
title: 'Request Challenge'
method: 'POST'
endpoint: '/auth/authreq'
number: 2
title: 'Request Challenge'
method: 'POST'
endpoint: '/auth/authreq'
description: 'Request an authentication challenge'
example: '{\n "pubkey": "your_public_key_here"\n}'
example: '{\n "pubkey": "your_public_key_here"\n}'
},
AuthStep{
number: 3
title: 'Submit Signature'
method: 'POST'
endpoint: '/auth/auth'
number: 3
title: 'Submit Signature'
method: 'POST'
endpoint: '/auth/auth'
description: 'Sign the challenge and submit for authentication'
example: '{\n "pubkey": "your_public_key_here",\n "signature": "signed_challenge"\n}'
example: '{\n "pubkey": "your_public_key_here",\n "signature": "signed_challenge"\n}'
},
AuthStep{
number: 4
title: 'Use Session Key'
method: 'ALL'
endpoint: '/api/{handler}/{method}'
number: 4
title: 'Use Session Key'
method: 'ALL'
endpoint: '/api/{handler}/{method}'
description: 'Include session key in Authorization header for all API calls'
example: 'Authorization: Bearer {session_key}'
}
example: 'Authorization: Bearer {session_key}'
},
]
}
}
}

View File

@@ -4,6 +4,30 @@ pub fn (schema Schema) type_() string {
return schema.typ.str()
}
// example_value generates a basic example value based on the schema type.
// Returns a JSON-formatted string appropriate for the schema type.
pub fn (schema Schema) example_value() string {
return ''
}
// Check if schema has an explicit example value (ignore empty arrays which indicate no example)
example_str := schema.example.str()
if example_str != '' && example_str != '[]' {
// For object examples, return the JSON string as-is
if schema.typ == 'object' || example_str.starts_with('{') {
return example_str
}
// For string types, ensure proper JSON formatting with quotes
if schema.typ == 'string' && !example_str.starts_with('"') {
return '"${example_str}"'
}
return example_str
}
// Generate type-based example when no explicit example is provided
match schema.typ {
'string' { return '"example_value"' }
'integer', 'number' { return '42' }
'boolean' { return 'true' }
'array' { return '[]' }
'object' { return '{}' }
else { return '"example_value"' }
}
}

View File

@@ -3,9 +3,14 @@ module jsonschema
import x.json2 { Any }
import json
// decode parses a JSON Schema string into a Schema struct.
// Handles complex fields like properties, additionalProperties, items, and examples
// that require custom parsing beyond standard JSON decoding.
pub fn decode(data string) !Schema {
schema_map := json2.raw_decode(data)!.as_map()
mut schema := json.decode(Schema, data)!
// Process fields that require custom decoding
for key, value in schema_map {
if key == 'properties' {
schema.properties = decode_schemaref_map(value.as_map())!
@@ -13,6 +18,9 @@ pub fn decode(data string) !Schema {
schema.additional_properties = decode_schemaref(value.as_map())!
} else if key == 'items' {
schema.items = decode_items(value)!
} else if key == 'example' {
// Manually handle example field since it's marked with @[json: '-'] in the Schema struct
schema.example = value
}
}
return schema
@@ -41,11 +49,15 @@ pub fn decode_schemaref_map(data_map map[string]Any) !map[string]SchemaRef {
return schemaref_map
}
// decode_schemaref parses a map into either a Schema or Reference.
// Handles both direct schema definitions and $ref references to external schemas.
pub fn decode_schemaref(data_map map[string]Any) !SchemaRef {
if ref := data_map['\$ref'] {
return Reference{
ref: ref.str()
}
}
return decode(data_map.str())!
// Convert map back to JSON string for proper schema decoding with custom field handling
json_str := json2.encode(data_map)
return decode(json_str)!
}

View File

@@ -42,3 +42,49 @@ fn test_decode_schemaref() ! {
required: ['name']
}
}
fn test_decode_with_example() ! {
// Test schema with example field
schema_with_example := '{
"type": "string",
"description": "A test string",
"example": "test_value"
}'
schema := decode(schema_with_example)!
assert schema.typ == 'string'
assert schema.description == 'A test string'
assert schema.example.str() == 'test_value'
}
fn test_decode_with_object_example() ! {
// Test schema with object example
schema_with_object_example := '{
"type": "object",
"description": "A test object",
"example": {
"name": "test",
"value": 123
}
}'
schema := decode(schema_with_object_example)!
assert schema.typ == 'object'
assert schema.description == 'A test object'
// Object examples are stored as json2.Any and need special handling
assert schema.example.str().contains('test')
}
fn test_decode_without_example() ! {
// Test schema without example field
schema_without_example := '{
"type": "integer",
"description": "A test integer"
}'
schema := decode(schema_without_example)!
assert schema.typ == 'integer'
assert schema.description == 'A test integer'
// Should have empty example
assert schema.example.str() == '[]'
}

View File

@@ -30,19 +30,20 @@ pub fn decode(data string) !OpenRPC {
for i, method in methods_any.arr() {
method_map := method.as_map()
// TODO: I had to disable this because it was not working, need to check why !!!!!
// Decode result
if result_any := method_map['result'] {
object.methods[i].result = decode_content_descriptor_ref(result_any.as_map()) or {
return error('Failed to decode result\n${err}')
}
}
// if result_any := method_map['result'] {
// object.methods[i].result = decode_content_descriptor_ref(result_any.as_map()) or {
// return error('Failed to decode result\n${err}')
// }
// }
// if params_any := method_map['params'] {
// params_arr := params_any.arr()
// object.methods[i].params = params_arr.map(decode_content_descriptor_ref(it.as_map()) or {
// return error('Failed to decode params\n${err}')
// })
// }
// Decode params
if params_any := method_map['params'] {
params_arr := params_any.arr()
object.methods[i].params = params_arr.map(decode_content_descriptor_ref(it.as_map()) or {
return error('Failed to decode params\n${err}')
})
}
}
// object.methods = decode_method(data_map['methods'].as_array)!
return object
@@ -96,7 +97,16 @@ fn decode_content_descriptor_ref(data_map map[string]Any) !ContentDescriptorRef
ref: ref_any.str()
}
}
mut descriptor := json2.decode[ContentDescriptor](data_map.str())!
// Create ContentDescriptor from map fields
mut descriptor := ContentDescriptor{
name: data_map['name'] or { Any('') }.str()
summary: data_map['summary'] or { Any('') }.str()
description: data_map['description'] or { Any('') }.str()
required: data_map['required'] or { Any(false) }.bool()
deprecated: data_map['deprecated'] or { Any(false) }.bool()
}
if schema_any := data_map['schema'] {
descriptor.schema = decode_schemaref(schema_any.as_map())!
}

View File

@@ -27,5 +27,5 @@ pub fn new(params Params) !OpenRPC {
params.text
}
return json.decode(OpenRPC, text)!
return decode(text)!
}