typescript client generation wip

This commit is contained in:
timurgordon
2025-01-24 04:31:49 +01:00
parent b9b21ac44b
commit ee8fbbca09
7 changed files with 544 additions and 53 deletions

View File

@@ -0,0 +1,130 @@
module generator
import freeflowuniverse.herolib.core.code {Folder, File}
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.schemas.jsonschema.codegen { schema_to_struct }
import freeflowuniverse.herolib.schemas.openrpc.codegen as openrpc_codegen { content_descriptor_to_parameter }
import freeflowuniverse.herolib.baobab.specification {ActorSpecification, ActorMethod, BaseObject}
pub fn typescript_client_folder(spec ActorSpecification) code.Folder {
return Folder {
name: 'client_typescript'
files: [
ts_client_model_file(spec.objects),
ts_client_methods_file(spec)
]
}
}
// generates a model.ts file for given base objects
fn ts_client_model_file(objs []BaseObject) File {
return File {
name: 'model'
extension: 'ts'
content: objs.map(schema_to_struct(it.schema) or {panic(err)})
.map(it.typescript())
.join_lines()
}
}
// generates a methods.ts file for given actor methods
pub fn ts_client_methods_file(spec_ ActorSpecification) File {
spec := spec_.validate()
mut files := []File{}
mut methods := []string{}
// for each base object generate ts client methods
// for the objects existing CRUD+LF methods
for obj in spec.objects {
if m := obj.new_method {
methods << ts_client_new_fn(obj.name())
}
if m := obj.get_method {
methods << ts_client_get_fn(obj.name())
}
if m := obj.set_method {
methods << ts_client_set_fn(obj.name())
}
if m := obj.delete_method {
methods << ts_client_delete_fn(obj.name())
}
if m := obj.list_method {
methods << ts_client_list_fn(obj.name())
}
methods << obj.other_methods.map(ts_client_fn_prototype(it))
}
return File {
name: 'methods'
extension: 'ts'
content: methods.join_lines()
}
}
@[params]
pub struct TSClientFunctionParams {
endpoint string // prefix for the Rest API endpoint
}
fn get_endpoint_root(root string) string {
return if root == '' {
''
} else {
"/${root.trim('/')}"
}
}
// generates a Base Object's `create` method
pub fn ts_client_new_fn(object string, params TSClientFunctionParams) string {
name_snake := texttools.name_fix_snake(object)
name_pascal := texttools.name_fix_pascal(object)
root := get_endpoint_root(params.endpoint)
return "async create${name_snake}(object: Omit<${name_pascal}, 'id'>): Promise<${name_pascal}> {
return this.restClient.post<${name_pascal}>('${root}/${name_snake}', board);
}"
}
pub fn ts_client_get_fn(object string, params TSClientFunctionParams) string {
name_snake := texttools.name_fix_snake(object)
name_pascal := texttools.name_fix_pascal(object)
root := get_endpoint_root(params.endpoint)
return "async get${name_pascal}(id: string): Promise<${name_pascal}> {\n return this.restClient.get<${name_pascal}>(`/${root}/${name_snake}/\${id}`);\n }"
}
pub fn ts_client_set_fn(object string, params TSClientFunctionParams) string {
name_snake := texttools.name_fix_snake(object)
name_pascal := texttools.name_fix_pascal(object)
root := get_endpoint_root(params.endpoint)
return "async set${name_pascal}(id: string, ${name_snake}: Partial<${name_pascal}>): Promise<${name_pascal}> {\n return this.restClient.put<${name_pascal}>(`/${root}/${name_snake}/\${id}`, ${name_snake});\n }"
}
pub fn ts_client_delete_fn(object string, params TSClientFunctionParams) string {
name_snake := texttools.name_fix_snake(object)
name_pascal := texttools.name_fix_pascal(object)
root := get_endpoint_root(params.endpoint)
return "async delete${name_pascal}(id: string): Promise<void> {\n return this.restClient.delete<void>(`/${root}/${name_snake}/\${id}`);\n }"
}
pub fn ts_client_list_fn(object string, params TSClientFunctionParams) string {
name_snake := texttools.name_fix_snake(object)
name_pascal := texttools.name_fix_pascal(object)
root := get_endpoint_root(params.endpoint)
return "async list${name_pascal}(): Promise<${name_pascal}[]> {\n return this.restClient.get<${name_pascal}[]>(`/${root}/${name_snake}`);\n }"
}
// generates a function prototype given an `ActorMethod`
pub fn ts_client_fn_prototype(method ActorMethod) string {
name := texttools.name_fix_pascal(method.name)
params := method.parameters
.map(content_descriptor_to_parameter(it) or {panic(err)})
.map(it.typescript())
.join(', ')
return_type := content_descriptor_to_parameter(method.result) or {panic(err)}.typ.typescript()
return 'async ${name}(${params}): Promise<${return_type}> {}'
}

View File

@@ -0,0 +1,202 @@
module generator
import x.json2 as json
import arrays
import freeflowuniverse.herolib.core.code
import freeflowuniverse.herolib.baobab.specification
import freeflowuniverse.herolib.schemas.openrpc
import freeflowuniverse.herolib.schemas.jsonschema
const specification = specification.ActorSpecification{
name: 'Pet Store'
description: 'A sample API for a pet store'
structure: code.Struct{}
interfaces: [.openapi]
methods: [
specification.ActorMethod{
name: 'listPets'
summary: 'List all pets'
example: openrpc.ExamplePairing{
params: [
openrpc.ExampleRef(openrpc.Example{
name: 'Example limit'
description: 'Example Maximum number of pets to return'
value: 10
})
]
result: openrpc.ExampleRef(openrpc.Example{
name: 'Example response'
value: json.raw_decode('[
{"id": 1, "name": "Fluffy", "tag": "dog"},
{"id": 2, "name": "Whiskers", "tag": "cat"}
]')!
})
}
parameters: [
openrpc.ContentDescriptor{
name: 'limit'
summary: 'Maximum number of pets to return'
description: 'Maximum number of pets to return'
required: false
schema: jsonschema.SchemaRef(jsonschema.Schema{
...jsonschema.schema_u32,
example: 10
})
}
]
result: openrpc.ContentDescriptor{
name: 'pets'
description: 'A paged array of pets'
schema: jsonschema.SchemaRef(jsonschema.Schema{
typ: 'array'
items: jsonschema.Items(jsonschema.SchemaRef(jsonschema.Schema{
id: 'pet'
title: 'Pet'
typ: 'object'
properties: {
'id': jsonschema.SchemaRef(jsonschema.Reference{
ref: '#/components/schemas/PetId'
}),
'name': jsonschema.SchemaRef(jsonschema.Schema{
typ: 'string'
}),
'tag': jsonschema.SchemaRef(jsonschema.Schema{
typ: 'string'
})
}
required: ['id', 'name']
}))
})
}
errors: [
openrpc.ErrorSpec{
code: 400
message: 'Invalid request'
}
]
},
specification.ActorMethod{
name: 'createPet'
summary: 'Create a new pet'
example: openrpc.ExamplePairing{
result: openrpc.ExampleRef(openrpc.Example{
name: 'Example response'
value: '[]'
})
}
result: openrpc.ContentDescriptor{
name: 'result'
description: 'The response of the operation.'
required: true
}
errors: [
openrpc.ErrorSpec{
code: 400
message: 'Invalid input'
}
]
},
specification.ActorMethod{
name: 'getPet'
summary: 'Get a pet by ID'
example: openrpc.ExamplePairing{
params: [
openrpc.ExampleRef(openrpc.Example{
name: 'Example petId'
description: 'Example ID of the pet to retrieve'
value: 1
})
]
result: openrpc.ExampleRef(openrpc.Example{
name: 'Example response'
value: json.raw_decode('{"id": 1, "name": "Fluffy", "tag": "dog"}')!
})
}
parameters: [
openrpc.ContentDescriptor{
name: 'petId'
summary: 'ID of the pet to retrieve'
description: 'ID of the pet to retrieve'
required: true
schema: jsonschema.SchemaRef(jsonschema.Schema{
...jsonschema.schema_u32,
format:'uint32'
example: 1
})
}
]
result: openrpc.ContentDescriptor{
name: 'result'
description: 'The response of the operation.'
required: true
schema: jsonschema.SchemaRef(jsonschema.Reference{
ref: '#/components/schemas/Pet'
})
}
errors: [
openrpc.ErrorSpec{
code: 404
message: 'Pet not found'
}
]
},
specification.ActorMethod{
name: 'deletePet'
summary: 'Delete a pet by ID'
example: openrpc.ExamplePairing{
params: [
openrpc.ExampleRef(openrpc.Example{
name: 'Example petId'
description: 'Example ID of the pet to delete'
value: 1
})
]
}
parameters: [
openrpc.ContentDescriptor{
name: 'petId'
summary: 'ID of the pet to delete'
description: 'ID of the pet to delete'
required: true
schema: jsonschema.SchemaRef(jsonschema.Schema{
...jsonschema.schema_u32,
example: 1
})
}
]
result: openrpc.ContentDescriptor{
name: 'result'
description: 'The response of the operation.'
required: true
}
errors: [
openrpc.ErrorSpec{
code: 404
message: 'Pet not found'
}
]
}
]
objects: [
specification.BaseObject{
schema: jsonschema.Schema{
title: 'Pet'
typ: 'object'
properties: {
'id': jsonschema.schema_u32,
'name': jsonschema.SchemaRef(jsonschema.Schema{
typ: 'string'
}),
'tag': jsonschema.SchemaRef(jsonschema.Schema{
typ: 'string'
})
}
required: ['id', 'name']
}
}
]
}
fn test_typescript_client_folder() {
client := typescript_client_folder(specification)
}

View File

@@ -1,6 +1,6 @@
module generator
import freeflowuniverse.herolib.core.code { Folder, IFile, VFile, CodeItem, File, Function, Import, Module, Struct, CustomCode }
import freeflowuniverse.herolib.core.code { Folder, IFolder, IFile, VFile, CodeItem, File, Function, Import, Module, Struct, CustomCode }
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.baobab.specification {ActorMethod, ActorSpecification, ActorInterface}
import json
@@ -13,6 +13,7 @@ pub:
pub fn generate_actor_module(spec ActorSpecification, params Params) !Module {
mut files := []IFile{}
mut folders := []IFolder{}
files = [
generate_readme_file(spec)!,
@@ -56,9 +57,9 @@ pub fn generate_actor_module(spec ActorSpecification, params Params) !Module {
files << iface_file
files << iface_test_file
// add openrpc.json to docs
// TODO
// add openapi.json to docs
docs_files << generate_openapi_file(openapi_spec)!
folders << typescript_client_folder(spec)
}
.http {
// interfaces that have http controllers
@@ -79,20 +80,18 @@ pub fn generate_actor_module(spec ActorSpecification, params Params) !Module {
// folder with docs
docs_folder := Folder {
folders << Folder {
name: 'docs'
files: docs_files
}
folders << generate_scripts_folder()
// create module with code files and docs folder
name_fixed := texttools.name_fix_snake(spec.name)
return code.new_module(
name: '${name_fixed}_actor'
files: files
folders: [
docs_folder,
generate_scripts_folder()
]
folders: folders
)
}

View File

@@ -76,8 +76,32 @@ const actor_spec = specification.ActorSpecification{
]
},
specification.ActorMethod{
name: 'createPet'
name: 'newPet'
summary: 'Create a new pet'
parameters: [
openrpc.ContentDescriptor{
name: 'result'
description: 'The response of the operation.'
required: true
schema: jsonschema.SchemaRef(jsonschema.Schema{
id: 'pet'
title: 'Pet'
typ: 'object'
properties: {
'id': jsonschema.SchemaRef(jsonschema.Reference{
ref: '#/components/schemas/PetId'
}),
'name': jsonschema.SchemaRef(jsonschema.Schema{
typ: 'string'
}),
'tag': jsonschema.SchemaRef(jsonschema.Schema{
typ: 'string'
})
}
required: ['id', 'name']
})
}
]
example: openrpc.ExamplePairing{
result: openrpc.ExampleRef(openrpc.Example{
name: 'Example response'
@@ -85,9 +109,14 @@ const actor_spec = specification.ActorSpecification{
})
}
result: openrpc.ContentDescriptor{
name: 'result'
description: 'The response of the operation.'
name: 'petId'
summary: 'ID of the created pet'
description: 'ID of the created pet'
required: true
schema: jsonschema.SchemaRef(jsonschema.Schema{
...jsonschema.schema_u32,
example: 1
})
}
errors: [
openrpc.ErrorSpec{

View File

@@ -16,19 +16,13 @@ pub fn generate_methods_file(spec ActorSpecification) !VFile {
method_fn := generate_method_function(spec.name, method)!
// check if method is a Base Object CRUD Method and
// if so generate the method's body
body := if is_base_object_new_method(method) {
generate_base_object_new_body(method)!
} else if is_base_object_get_method(method) {
generate_base_object_get_body(method)!
} else if is_base_object_set_method(method) {
generate_base_object_set_body(method)!
} else if is_base_object_delete_method(method) {
generate_base_object_delete_body(method)!
} else if is_base_object_list_method(method) {
generate_base_object_list_body(method)!
} else {
// default actor method body
"panic('implement')"
body := match spec.method_type(method) {
.base_object_new { base_object_new_body(method)! }
.base_object_get { base_object_get_body(method)! }
.base_object_set { base_object_set_body(method)! }
.base_object_delete { base_object_delete_body(method)! }
.base_object_list { base_object_list_body(method)! }
else {"panic('implement')"}
}
items << Function{...method_fn, body: body}
}
@@ -53,50 +47,29 @@ pub fn generate_method_function(actor_name string, method ActorMethod) !Function
}
}
fn is_base_object_new_method(method ActorMethod) bool {
return method.name.starts_with('new')
}
fn is_base_object_get_method(method ActorMethod) bool {
return method.name.starts_with('get')
}
fn is_base_object_set_method(method ActorMethod) bool {
return method.name.starts_with('set')
}
fn is_base_object_delete_method(method ActorMethod) bool {
return method.name.starts_with('delete')
}
fn is_base_object_list_method(method ActorMethod) bool {
return method.name.starts_with('list')
}
fn generate_base_object_new_body(method ActorMethod) !string {
fn base_object_new_body(method ActorMethod) !string {
parameter := content_descriptor_to_parameter(method.parameters[0])!
return 'return actor.osis.new[${parameter.typ.vgen()}](${texttools.name_fix_snake(parameter.name)})!'
}
fn generate_base_object_get_body(method ActorMethod) !string {
fn base_object_get_body(method ActorMethod) !string {
parameter := content_descriptor_to_parameter(method.parameters[0])!
result := content_descriptor_to_parameter(method.result)!
return 'return actor.osis.get[${result.typ.vgen()}](${texttools.name_fix_snake(parameter.name)})!'
}
fn generate_base_object_set_body(method ActorMethod) !string {
fn base_object_set_body(method ActorMethod) !string {
parameter := content_descriptor_to_parameter(method.parameters[0])!
return 'return actor.osis.set[${parameter.typ.vgen()}](${parameter.name})!'
}
fn generate_base_object_delete_body(method ActorMethod) !string {
fn base_object_delete_body(method ActorMethod) !string {
parameter := content_descriptor_to_parameter(method.parameters[0])!
return 'actor.osis.delete(${texttools.name_fix_snake(parameter.name)})!'
}
fn generate_base_object_list_body(method ActorMethod) !string {
fn base_object_list_body(method ActorMethod) !string {
result := content_descriptor_to_parameter(method.result)!
base_object_type := (result.typ as Array).typ
return 'return actor.osis.list[${base_object_type.symbol()}]()!'
}

View File

@@ -133,7 +133,7 @@ pub fn from_openapi(spec OpenAPI) !ActorSpecification {
// Extract objects from OpenAPI components.schemas
for name, schema in spec.components.schemas {
objects << BaseObject{schema as Schema}
objects << BaseObject{schema: schema as Schema}
}
return ActorSpecification{

View File

@@ -2,7 +2,7 @@ module specification
import freeflowuniverse.herolib.core.code { Struct, Function }
import freeflowuniverse.herolib.schemas.openrpc {ExamplePairing, ContentDescriptor, ErrorSpec}
import freeflowuniverse.herolib.schemas.jsonschema {Schema}
import freeflowuniverse.herolib.schemas.jsonschema {Schema, Reference}
pub struct ActorSpecification {
pub mut:
@@ -34,6 +34,164 @@ pub:
}
pub struct BaseObject {
pub:
pub mut:
schema Schema
new_method ?ActorMethod
get_method ?ActorMethod
set_method ?ActorMethod
delete_method ?ActorMethod
list_method ?ActorMethod
filter_method ?ActorMethod
other_methods []ActorMethod
}
pub enum MethodCategory {
base_object_new
base_object_get
base_object_set
base_object_delete
base_object_list
other
}
// returns whether method belongs to a given base object
// TODO: link to more info about base object methods
fn (m ActorMethod) belongs_to_object(obj BaseObject) bool {
base_obj_is_param := m.parameters
.filter(it.schema is Schema)
.map(it.schema as Schema)
.any(it.id == obj.schema.id)
base_obj_is_result := if m.result.schema is Schema {
m.result.schema.id == obj.schema.id
} else {
ref := m.result.schema as Reference
ref.ref.all_after_last('/') == obj.name()
}
return base_obj_is_param || base_obj_is_result
}
pub fn (s ActorSpecification) validate() ActorSpecification {
mut validated_objects := []BaseObject{}
for obj_ in s.objects {
mut obj := obj_
if obj.schema.id == '' {
obj.schema.id = obj.schema.title
}
methods := s.methods.filter(it.belongs_to_object(obj))
if m := methods.filter(it.is_new_method())[0] {
obj.new_method = m
}
if m := methods.filter(it.is_set_method())[0] {
obj.set_method = m
}
if m := methods.filter(it.is_get_method())[0] {
obj.get_method = m
}
if m := methods.filter(it.is_delete_method())[0] {
obj.delete_method = m
}
if m := methods.filter(it.is_list_method())[0] {
obj.list_method = m
}
validated_objects << BaseObject {
...obj
other_methods: methods.filter(!it.is_crudlf_method())
}
}
return ActorSpecification {
...s,
objects: validated_objects
}
}
// method category returns what category a method falls under
pub fn (s ActorSpecification) method_type(method ActorMethod) MethodCategory {
return if s.is_base_object_new_method(method) {
.base_object_new
} else if s.is_base_object_get_method(method) {
.base_object_get
} else if s.is_base_object_set_method(method) {
.base_object_set
} else if s.is_base_object_delete_method(method) {
.base_object_delete
} else if s.is_base_object_list_method(method) {
.base_object_list
} else {
.other
}
}
// a base object method is a method that is a
// CRUD+list+filter method of a base object
fn (s ActorSpecification) is_base_object_method(method ActorMethod) bool {
base_obj_is_param := method.parameters
.filter(it.schema is Schema)
.map(it.schema as Schema)
.any(it.id in s.objects.map(it.schema.id))
base_obj_is_result := if method.result.schema is Schema {
method.result.schema.id in s.objects.map(it.name())
} else {
ref := method.result.schema as Reference
ref.ref.all_after_last('/') in s.objects.map(it.name())
}
return base_obj_is_param || base_obj_is_result
}
fn (m ActorMethod) is_new_method() bool {
return m.name.starts_with('new')
}
fn (m ActorMethod) is_get_method() bool {
return m.name.starts_with('get')
}
fn (m ActorMethod) is_set_method() bool {
return m.name.starts_with('set')
}
fn (m ActorMethod) is_delete_method() bool {
return m.name.starts_with('delete')
}
fn (m ActorMethod) is_list_method() bool {
return m.name.starts_with('list')
}
fn (m ActorMethod) is_filter_method() bool {
return m.name.starts_with('filter')
}
fn (m ActorMethod) is_crudlf_method() bool {
return m.is_new_method() ||
m.is_get_method() ||
m.is_set_method() ||
m.is_delete_method() ||
m.is_list_method() ||
m.is_filter_method()
}
pub fn (o BaseObject) name() string {
return if o.schema.id.trim_space() != '' {
o.schema.id.trim_space()
} else {o.schema.title.trim_space()}
}
fn (s ActorSpecification) is_base_object_new_method(method ActorMethod) bool {
return s.is_base_object_method(method) && method.name.starts_with('new')
}
fn (s ActorSpecification) is_base_object_get_method(method ActorMethod) bool {
return s.is_base_object_method(method) && method.name.starts_with('get')
}
fn (s ActorSpecification) is_base_object_set_method(method ActorMethod) bool {
return s.is_base_object_method(method) && method.name.starts_with('set')
}
fn (s ActorSpecification) is_base_object_delete_method(method ActorMethod) bool {
return s.is_base_object_method(method) && method.name.starts_with('delete')
}
fn (s ActorSpecification) is_base_object_list_method(method ActorMethod) bool {
return s.is_base_object_method(method) && method.name.starts_with('list')
}