diff --git a/lib/baobab/generator/client_typescript.v b/lib/baobab/generator/client_typescript.v new file mode 100644 index 00000000..3d4c4398 --- /dev/null +++ b/lib/baobab/generator/client_typescript.v @@ -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 {\n return this.restClient.delete(`/${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}> {}' +} \ No newline at end of file diff --git a/lib/baobab/generator/client_typescript_test.v b/lib/baobab/generator/client_typescript_test.v new file mode 100644 index 00000000..2c3140fc --- /dev/null +++ b/lib/baobab/generator/client_typescript_test.v @@ -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) +} diff --git a/lib/baobab/generator/generate_actor.v b/lib/baobab/generator/generate_actor.v index dcabcca4..bfc154f6 100644 --- a/lib/baobab/generator/generate_actor.v +++ b/lib/baobab/generator/generate_actor.v @@ -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 ) } diff --git a/lib/baobab/generator/generate_actor_test.v b/lib/baobab/generator/generate_actor_test.v index d10d4836..f3c1f587 100644 --- a/lib/baobab/generator/generate_actor_test.v +++ b/lib/baobab/generator/generate_actor_test.v @@ -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{ diff --git a/lib/baobab/generator/generate_methods.v b/lib/baobab/generator/generate_methods.v index 3bd57d12..df0d8506 100644 --- a/lib/baobab/generator/generate_methods.v +++ b/lib/baobab/generator/generate_methods.v @@ -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()}]()!' } diff --git a/lib/baobab/specification/from_openapi.v b/lib/baobab/specification/from_openapi.v index b6dcbe8f..640075b2 100644 --- a/lib/baobab/specification/from_openapi.v +++ b/lib/baobab/specification/from_openapi.v @@ -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{ diff --git a/lib/baobab/specification/model.v b/lib/baobab/specification/model.v index a6e43fd0..e00f18ed 100644 --- a/lib/baobab/specification/model.v +++ b/lib/baobab/specification/model.v @@ -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') } \ No newline at end of file