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:
@@ -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": {}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}'
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)!
|
||||
}
|
||||
|
||||
@@ -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() == '[]'
|
||||
}
|
||||
|
||||
@@ -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())!
|
||||
}
|
||||
|
||||
@@ -27,5 +27,5 @@ pub fn new(params Params) !OpenRPC {
|
||||
params.text
|
||||
}
|
||||
|
||||
return json.decode(OpenRPC, text)!
|
||||
return decode(text)!
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user