Go ProJor example
INFO
In this full example, we will show how to use ProJor to generate an application using the Go programming language.
The application will be responsible for storing and fetching resources of different types. The program model will define the persistent types. We will use the templates to generate the backend, client, and documentation for the application.
Click here to access the source code of the example.
Repository structure
As the first step, we are going to create the .projor/
directory to hold all of our schemas, data collections and templates.
Inside the .projor
directory, we will create the following subdirectories:
data/
- to hold the data collections.schema/
- to hold the schemas.template/
- to hold the templates.
Program model schemas
Our next step is to create the schemas of the program model. In this application, we are going to deal with different types of resources. Each resource may have different attributes. We are going to create the following schemas (in the .projor/schema
directory):
- BasicType - A basic type for attributes.
- Field - A field in a resource.
- Resource - A persistent resource type.
The BasicType.pschema.yaml
schema
id: BasicType
name: BasicType
description: A basic type for resource attributes
fields:
- name: go
type: string
description: The Go representation of the type
- name: ts
type: string
description: The TypeScript representation of the type
The Field.pschema.yaml
schema
id: Field
name: Field
description: A field in a resource
fields:
- name: type
type: reference
references: BasicType
description: The type of the field
- name: isList
type: boolean
description: Whether the field is a list
The Resource.pschema.yaml
schema
id: Resource
name: Resource
description: A persistent resource type
fields:
- name: fields
type: attachment
references: Field
multiple: true
description: The fields of the resource
Supporting basic types
Next, we will create a data collection called basic
, which will hold the basic types supported for resource attributes. This data collection is placed in the .projor/data
directory.
The basic.pdata.yaml
data collection
id: basic
name: Basic Types
description: Basic types supported for resource attributes
schema: BasicType
objects:
- name: String
description: String
go: string
ts: string
- name: Int32
description: 32-bit integer
go: int32
ts: number
- name: Float32
description: 32-bit floating point number
go: float32
ts: number
- name: Boolean
description: Boolean
go: bool
ts: boolean
The resources data collection
Next, we are going to define the resources.pdata.yaml
data collection. This data collection is going to store all persistent resource types that are supported by the application.
INFO
We are not declaring id
field for our resources. Instead, we are going to make an assumption, that all resources should have an id: String
field.
The resources.pdata.yaml
data collection
id: resources
name: Resources
description: Persistent resource types
schema: Resource
objects:
- name: UserAccount
description: The user accounts in the system
fields:
- name: username
description: The username of the user account
type: basic#String
isList: false
- name: email
description: The email address of the user account
type: basic#String
isList: false
- name: flags
description: The flags of the user account
type: basic#String
isList: true
- name: Product
description: The products in the system
fields:
- name: name
description: The name of the product
type: basic#String
isList: false
- name: price
description: The price of the product
type: basic#Float32
isList: false
- name: description
description: The description of the product
type: basic#String
isList: false
Setting up the Go project
It is now time to start working on our actual application.
Before diving into the necessary code templates, we must set up the Go project structure.
We can use the go mod init
command to do so.
go mod init github.com/SIOCODE-Open/projor-go-example
Next we can add a main.go
file to the project. Let's just define a simple "Hello, world!"
application, and see if compilation works.
main.go
package main
import "fmt"
func main() {
fmt.Printf("Hello, world!\n")
}
We can now build the project with the go build
command. We should get an executable file named projor-go-example
(or projor-go-example.exe
on Windows).
Let's run the application to make sure everything is working as expected.
C:\work\siocode\projor-examples\go>projor-go-example.exe
Hello, world!
C:\work\siocode\projor-examples\go>
Resource persistence
Let's use the code generator now to add features to our application!
We are going to set up templates to generate CRUD (Create-Read-Update-Delete) functionality for all of our resources. Keep in mind, that the end result will be pretty complex, so we'll go step-by-step; in our first templates, let's model the data structures, and repository interfaces for our resources.
We are going to generate a separate file for each resource, that will be named name-of-resource.go
.
Let's create the resource-go.ptemplate.mustache
file in the .projor/template
directory.
The resource-go.ptemplate.mustache
template
{
"forEach": "resources",
"filename": "{{kebabCase name}}.go"
}
---
package main
type {{pascalCase name}} struct {
Id string
{{#each fields}}
{{pascalCase name}} {{#if isList}}[]{{/if}}{{type.go}}
{{/each}}
}
type I{{pascalCase name}}Repository interface {
Create({{camelCase name}} *{{pascalCase name}}) error
Update({{camelCase name}} *{{pascalCase name}}) error
Delete({{camelCase name}} *{{pascalCase name}}) error
GetByID(id string) (*{{pascalCase name}}, error)
GetAll() ([]{{pascalCase name}}, error)
}
- If using VS Code Extension, press
Ctrl+Shift+P
, and executeProjor: Generate code
. - If using CLI, run
projor generate
.
What you should get ...
The code generation results in two files being created.
user-account.go
package main
type UserAccount struct {
Id string
Username string
Email string
Flags []string
}
type IUserAccountRepository interface {
Create(userAccount *UserAccount) error
Update(userAccount *UserAccount) error
Delete(userAccount *UserAccount) error
GetByID(id string) (*UserAccount, error)
GetAll() ([]UserAccount, error)
}
product.go
package main
type Product struct {
Id string
Name string
Price float32
Description string
}
type IProductRepository interface {
Create(product *Product) error
Update(product *Product) error
Delete(product *Product) error
GetByID(id string) (*Product, error)
GetAll() ([]Product, error)
}
Persistence implementation
Now we will create a more advanced template for our resources. Our goals are the following:
- Each repository will hold an in-memory map of resources.
- The repository will implement the CRUD operations (
Create
,GetByID
,GetAll
,Update
,Delete
methods).
The resource-impl-go.ptemplate.mustache
template
{
"forEach": "resources",
"filename": "{{kebabCase name}}.go"
}
---
package main
import "errors"
type {{pascalCase name}} struct {
Id string
{{#each fields}}
{{pascalCase name}} {{#if isList}}[]{{/if}}{{type.go}}
{{/each}}
}
type I{{pascalCase name}}Repository interface {
Create({{camelCase name}} *{{pascalCase name}}) error
Update({{camelCase name}} *{{pascalCase name}}) error
Delete({{camelCase name}} *{{pascalCase name}}) error
GetByID(id string) (*{{pascalCase name}}, error)
GetAll() ([]{{pascalCase name}}, error)
}
type {{pascalCase name}}Repository struct {
I{{pascalCase name}}Repository
entities map[string]*{{pascalCase name}}
}
func New{{pascalCase name}}Repository() *{{pascalCase name}}Repository {
return &{{pascalCase name}}Repository{
entities: make(map[string]*{{pascalCase name}}),
}
}
func (repo *{{pascalCase name}}Repository) Create({{camelCase name}} *{{pascalCase name}}) error {
if _, ok := repo.entities[{{camelCase name}}.Id]; ok {
return errors.New("{{pascalCase name}} already exists")
}
repo.entities[{{camelCase name}}.Id] = {{camelCase name}}
return nil
}
func (repo *{{pascalCase name}}Repository) Update({{camelCase name}} *{{pascalCase name}}) error {
if _, ok := repo.entities[{{camelCase name}}.Id]; !ok {
return errors.New("{{pascalCase name}} not found")
}
repo.entities[{{camelCase name}}.Id] = {{camelCase name}}
return nil
}
func (repo *{{pascalCase name}}Repository) Delete({{camelCase name}} *{{pascalCase name}}) error {
if _, ok := repo.entities[{{camelCase name}}.Id]; !ok {
return errors.New("{{pascalCase name}} not found")
}
delete(repo.entities, {{camelCase name}}.Id)
return nil
}
func (repo *{{pascalCase name}}Repository) GetByID(id string) (*{{pascalCase name}}, error) {
if _, ok := repo.entities[id]; !ok {
return nil, errors.New("{{pascalCase name}} not found")
}
return repo.entities[id], nil
}
func (repo *{{pascalCase name}}Repository) GetAll() ([]{{pascalCase name}}, error) {
var result []{{pascalCase name}}
for _, entity := range repo.entities {
result = append(result, *entity)
}
return result, nil
}
Now, let's generate the code again.
What you should get ...
user-account.go
package main
import "errors"
type UserAccount struct {
Id string
Username string
Email string
Flags []string
}
type IUserAccountRepository interface {
Create(userAccount *UserAccount) error
Update(userAccount *UserAccount) error
Delete(userAccount *UserAccount) error
GetByID(id string) (*UserAccount, error)
GetAll() ([]UserAccount, error)
}
type UserAccountRepository struct {
IUserAccountRepository
entities map[string]*UserAccount
}
func NewUserAccountRepository() *UserAccountRepository {
return &UserAccountRepository{
entities: make(map[string]*UserAccount),
}
}
func (repo *UserAccountRepository) Create(userAccount *UserAccount) error {
if _, ok := repo.entities[userAccount.Id]; ok {
return errors.New("UserAccount already exists")
}
repo.entities[userAccount.Id] = userAccount
return nil
}
func (repo *UserAccountRepository) Update(userAccount *UserAccount) error {
if _, ok := repo.entities[userAccount.Id]; !ok {
return errors.New("UserAccount not found")
}
repo.entities[userAccount.Id] = userAccount
return nil
}
func (repo *UserAccountRepository) Delete(userAccount *UserAccount) error {
if _, ok := repo.entities[userAccount.Id]; !ok {
return errors.New("UserAccount not found")
}
delete(repo.entities, userAccount.Id)
return nil
}
func (repo *UserAccountRepository) GetByID(id string) (*UserAccount, error) {
if _, ok := repo.entities[id]; !ok {
return nil, errors.New("UserAccount not found")
}
return repo.entities[id], nil
}
func (repo *UserAccountRepository) GetAll() ([]UserAccount, error) {
var result []UserAccount
for _, entity := range repo.entities {
result = append(result, *entity)
}
return result, nil
}
product.go
package main
import "errors"
type Product struct {
Id string
Name string
Price float32
Description string
}
type IProductRepository interface {
Create(product *Product) error
Update(product *Product) error
Delete(product *Product) error
GetByID(id string) (*Product, error)
GetAll() ([]Product, error)
}
type ProductRepository struct {
IProductRepository
entities map[string]*Product
}
func NewProductRepository() *ProductRepository {
return &ProductRepository{
entities: make(map[string]*Product),
}
}
func (repo *ProductRepository) Create(product *Product) error {
if _, ok := repo.entities[product.Id]; ok {
return errors.New("Product already exists")
}
repo.entities[product.Id] = product
return nil
}
func (repo *ProductRepository) Update(product *Product) error {
if _, ok := repo.entities[product.Id]; !ok {
return errors.New("Product not found")
}
repo.entities[product.Id] = product
return nil
}
func (repo *ProductRepository) Delete(product *Product) error {
if _, ok := repo.entities[product.Id]; !ok {
return errors.New("Product not found")
}
delete(repo.entities, product.Id)
return nil
}
func (repo *ProductRepository) GetByID(id string) (*Product, error) {
if _, ok := repo.entities[id]; !ok {
return nil, errors.New("Product not found")
}
return repo.entities[id], nil
}
func (repo *ProductRepository) GetAll() ([]Product, error) {
var result []Product
for _, entity := range repo.entities {
result = append(result, *entity)
}
return result, nil
}
Completing the backend
We will now create a new template for generating the main.go
file.
In our entrypoint, our backend should do the following:
- Create instances of the repositories.
- Define the HTTP API ...
- Define the
GET /name-of-resource
endpoint to list all resources. - Define the
GET /name-of-resource/{id}
endpoint to get a single resource. - Define the
POST /name-of-resource
endpoint to create a new resource. - Define the
PUT /name-of-resource/{id}
endpoint to update a resource. - Define the
DELETE /name-of-resource/{id}
endpoint to delete a resource.
- Define the
But first we have some modifications in the resource-go.ptemplate.mustache
file as well: we need to add the json:"name_of_field"
tags to our data structure struct
s.
The resource-go.ptemplate.mustache
file after modifications
{
"forEach": "resources",
"filename": "{{kebabCase name}}.go"
}
---
package main
import "errors"
type {{pascalCase name}} struct {
Id string `json:"id"`
{{#each fields}}
{{pascalCase name}} {{#if isList}}[]{{/if}}{{type.go}} `json:"{{snakeCase name}}"`
{{/each}}
}
type I{{pascalCase name}}Repository interface {
Create({{camelCase name}} *{{pascalCase name}}) error
Update({{camelCase name}} *{{pascalCase name}}) error
Delete({{camelCase name}} *{{pascalCase name}}) error
GetByID(id string) (*{{pascalCase name}}, error)
GetAll() ([]{{pascalCase name}}, error)
}
type {{pascalCase name}}Repository struct {
I{{pascalCase name}}Repository
entities map[string]*{{pascalCase name}}
}
func New{{pascalCase name}}Repository() *{{pascalCase name}}Repository {
return &{{pascalCase name}}Repository{
entities: make(map[string]*{{pascalCase name}}),
}
}
func (repo *{{pascalCase name}}Repository) Create({{camelCase name}} *{{pascalCase name}}) error {
if _, ok := repo.entities[{{camelCase name}}.Id]; ok {
return errors.New("{{pascalCase name}} already exists")
}
repo.entities[{{camelCase name}}.Id] = {{camelCase name}}
return nil
}
func (repo *{{pascalCase name}}Repository) Update({{camelCase name}} *{{pascalCase name}}) error {
if _, ok := repo.entities[{{camelCase name}}.Id]; !ok {
return errors.New("{{pascalCase name}} not found")
}
repo.entities[{{camelCase name}}.Id] = {{camelCase name}}
return nil
}
func (repo *{{pascalCase name}}Repository) Delete({{camelCase name}} *{{pascalCase name}}) error {
if _, ok := repo.entities[{{camelCase name}}.Id]; !ok {
return errors.New("{{pascalCase name}} not found")
}
delete(repo.entities, {{camelCase name}}.Id)
return nil
}
func (repo *{{pascalCase name}}Repository) GetByID(id string) (*{{pascalCase name}}, error) {
if _, ok := repo.entities[id]; !ok {
return nil, errors.New("{{pascalCase name}} not found")
}
return repo.entities[id], nil
}
func (repo *{{pascalCase name}}Repository) GetAll() ([]{{pascalCase name}}, error) {
var result []{{pascalCase name}}
for _, entity := range repo.entities {
result = append(result, *entity)
}
return result, nil
}
Let's create the main-go.ptemplate.mustache
file in .projor/template
directory.
INFO
In the resource-go.ptemplate.mustache
template, we used the forEach
operator. This creates a separate file for each object in the interpolated data collection.
Now, we want to create a single file, that depends on our data. For this purpose, we are going to use the map
operator.
The main-go.ptemplate.mustache
template
{
"map": {
"r": "resources"
},
"filename": "main.go"
}
---
package main
import (
"fmt"
"net/http"
"encoding/json"
)
var (
{{#each r}}
{{camelCase name}}Repository = New{{pascalCase name}}Repository()
{{/each}}
)
{{#each r}}
func Handle{{pascalCase name}}Create(w http.ResponseWriter, r *http.Request) {
{{camelCase name}} := &{{pascalCase name}}{}
if err := json.NewDecoder(r.Body).Decode({{camelCase name}}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := {{camelCase name}}Repository.Create({{camelCase name}}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func Handle{{pascalCase name}}Update(w http.ResponseWriter, r *http.Request) {
{{camelCase name}} := &{{pascalCase name}}{}
if err := json.NewDecoder(r.Body).Decode({{camelCase name}}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := {{camelCase name}}Repository.Update({{camelCase name}}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func Handle{{pascalCase name}}Delete(w http.ResponseWriter, r *http.Request) {
{{camelCase name}} := &{{pascalCase name}}{}
if err := json.NewDecoder(r.Body).Decode({{camelCase name}}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := {{camelCase name}}Repository.Delete({{camelCase name}}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func Handle{{pascalCase name}}GetByID(w http.ResponseWriter, r *http.Request) {
id := r.URL.Path[len("/{{kebabCase name}}/"):]
{{camelCase name}}, err := {{camelCase name}}Repository.GetByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if err := json.NewEncoder(w).Encode({{camelCase name}}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func Handle{{pascalCase name}}GetAll(w http.ResponseWriter, r *http.Request) {
{{camelCase name}}s, err := {{camelCase name}}Repository.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode({{camelCase name}}s); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func Handle{{pascalCase name}}Request(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept")
switch r.Method {
case http.MethodOptions:
w.WriteHeader(http.StatusOK)
w.Header().Set("Allow", "GET, POST, PUT, DELETE, OPTIONS")
case http.MethodPost:
Handle{{pascalCase name}}Create(w, r)
case http.MethodPut:
Handle{{pascalCase name}}Update(w, r)
case http.MethodDelete:
Handle{{pascalCase name}}Delete(w, r)
case http.MethodGet:
if r.URL.Path == "/{{kebabCase name}}" {
Handle{{pascalCase name}}GetAll(w, r)
} else {
Handle{{pascalCase name}}GetByID(w, r)
}
default:
http.Error(w, fmt.Sprintf("Method %s not allowed", r.Method), http.StatusMethodNotAllowed)
}
}
{{/each}}
func main() {
{{#each r}}
http.HandleFunc("/{{kebabCase name}}", Handle{{pascalCase name}}Request)
{{/each}}
fmt.Println("Server running on port 8080")
http.ListenAndServe(":8080", nil)
}
The generated main.go
file
package main
import (
"fmt"
"net/http"
"encoding/json"
)
var (
userAccountRepository = NewUserAccountRepository()
productRepository = NewProductRepository()
)
func HandleUserAccountCreate(w http.ResponseWriter, r *http.Request) {
userAccount := &UserAccount{}
if err := json.NewDecoder(r.Body).Decode(userAccount); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := userAccountRepository.Create(userAccount); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func HandleUserAccountUpdate(w http.ResponseWriter, r *http.Request) {
userAccount := &UserAccount{}
if err := json.NewDecoder(r.Body).Decode(userAccount); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := userAccountRepository.Update(userAccount); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func HandleUserAccountDelete(w http.ResponseWriter, r *http.Request) {
userAccount := &UserAccount{}
if err := json.NewDecoder(r.Body).Decode(userAccount); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := userAccountRepository.Delete(userAccount); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func HandleUserAccountGetByID(w http.ResponseWriter, r *http.Request) {
// Get the ID from the path
id := r.URL.Path[len("/user-account/"):]
userAccount, err := userAccountRepository.GetByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if err := json.NewEncoder(w).Encode(userAccount); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func HandleUserAccountGetAll(w http.ResponseWriter, r *http.Request) {
userAccounts, err := userAccountRepository.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(userAccounts); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func HandleUserAccountRequest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept")
switch r.Method {
case http.MethodOptions:
w.WriteHeader(http.StatusOK)
w.Header().Set("Allow", "GET, POST, PUT, DELETE, OPTIONS")
case http.MethodPost:
HandleUserAccountCreate(w, r)
case http.MethodPut:
HandleUserAccountUpdate(w, r)
case http.MethodDelete:
HandleUserAccountDelete(w, r)
case http.MethodGet:
if r.URL.Path == "/user-account" {
HandleUserAccountGetAll(w, r)
} else {
HandleUserAccountGetByID(w, r)
}
default:
http.Error(w, fmt.Sprintf("Method %s not allowed", r.Method), http.StatusMethodNotAllowed)
}
}
func HandleProductCreate(w http.ResponseWriter, r *http.Request) {
product := &Product{}
if err := json.NewDecoder(r.Body).Decode(product); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := productRepository.Create(product); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func HandleProductUpdate(w http.ResponseWriter, r *http.Request) {
product := &Product{}
if err := json.NewDecoder(r.Body).Decode(product); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := productRepository.Update(product); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func HandleProductDelete(w http.ResponseWriter, r *http.Request) {
product := &Product{}
if err := json.NewDecoder(r.Body).Decode(product); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := productRepository.Delete(product); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func HandleProductGetByID(w http.ResponseWriter, r *http.Request) {
// Get the ID from the path
id := r.URL.Path[len("/product/"):]
product, err := productRepository.GetByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if err := json.NewEncoder(w).Encode(product); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func HandleProductGetAll(w http.ResponseWriter, r *http.Request) {
products, err := productRepository.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(products); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func HandleProductRequest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept")
switch r.Method {
case http.MethodOptions:
w.WriteHeader(http.StatusOK)
w.Header().Set("Allow", "GET, POST, PUT, DELETE, OPTIONS")
case http.MethodPost:
HandleProductCreate(w, r)
case http.MethodPut:
HandleProductUpdate(w, r)
case http.MethodDelete:
HandleProductDelete(w, r)
case http.MethodGet:
if r.URL.Path == "/product" {
HandleProductGetAll(w, r)
} else {
HandleProductGetByID(w, r)
}
default:
http.Error(w, fmt.Sprintf("Method %s not allowed", r.Method), http.StatusMethodNotAllowed)
}
}
func main() {
http.HandleFunc("/user-account", HandleUserAccountRequest)
http.HandleFunc("/product", HandleProductRequest)
fmt.Println("Server running on port 8080")
http.ListenAndServe(":8080", nil)
}
Now we can generate the code, and run go build
to build the application. After that, we can start it, and test using Postman.
Bootstrapping the client
We will now create a new template for generating a client library in TypeScript language for our application.
This client can then be used in either a browser-based application, or a Node.js application. We can also use it to test our backend. We will use it in a simple React-based frontend application.
Let's add a package.json
file to our project now.
The package.json
file
{
"name": "@siocode/projor-go-example",
"version": "0.0.1",
"private": true,
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"typescript": "^5.5.4",
"esbuild": "^0.23.0"
},
"scripts": {
"build": "esbuild src/index.tsx --bundle --outdir=public --platform=browser --minify --jsx=automatic",
"dev": "esbuild src/index.tsx --bundle --outdir=public --platform=browser --serve=0.0.0.0:3000 --servedir=public --jsx=automatic"
}
}
We will now add a tsconfig.json
file to the project as well.
The tsconfig.json
file
{
"compilerOptions": {
"target": "ES2015",
"module": "ES2015",
"moduleResolution": "Node",
"jsx": "react-native",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules"
]
}
We will also add the public/index.html
file.
The public/index.html
file
<!DOCTYPE html>
<html lang="en">
<head>
<title>ProJor Go Example</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="root"></div>
<script src="/index.js"></script>
</body>
</html>
Let's also create the src/index.tsx
file, with some Hello World content for now.
The src/index.tsx
file
import { createRoot } from 'react-dom/client';
function App() {
return (
<div>
<h1>Hello World</h1>
</div>
);
}
const rootEl = document.getElementById('root');
const root = createRoot(rootEl);
root.render(<App />);
We can now run the following commands, to see if everything works as expected.
npm install
npm run dev
Now you can open your browser and navigate to http://localhost:3000
to see the application running.
Generating the client
Let's create the client-ts.ptemplate.mustache
template in the .projor/template
directory.
The client-ts.ptemplate.mustache
template
{
"map": {
"r": "resources"
},
"filename": "src/client.ts"
}
---
{{#each r}}
/** {{{description}}} */
export interface I{{pascalCase name}} {
/** Unique identifier of the {{pascalCase name}} */
id: string;
{{#each fields}}
/** {{{description}}} */
{{snakeCase name}}: {{type.ts}};
{{/each}}
}
{{/each}}
Let's run the code generation. You should get a file called src/client.ts
, which contains the TypeScript interfaces for our resources.
The generated src/client.ts
file
/** The user accounts in the system */
export interface IUserAccount {
/** Unique identifier of the UserAccount */
id: string;
/** The username of the user account */
username: string;
/** The email address of the user account */
email: string;
/** The flags of the user account */
flags: string;
}
/** The products in the system */
export interface IProduct {
/** Unique identifier of the Product */
id: string;
/** The name of the product */
name: string;
/** The price of the product */
price: number;
/** The description of the product */
description: string;
}
Now let's improve on this template, and add fetch
-based API calls.
The client-ts.ptemplate.mustache
template with API calls
{
"map": {
"r": "resources"
},
"filename": "src/client.ts"
}
---
{{#each r}}
/** {{{description}}} */
export interface I{{pascalCase name}} {
/** Unique identifier of the {{pascalCase name}} */
id: string;
{{#each fields}}
/** {{{description}}} */
{{snakeCase name}}: {{type.ts}};
{{/each}}
}
export interface I{{pascalCase name}}Client {
create(data: I{{pascalCase name}}): Promise<void>;
update(data: I{{pascalCase name}}): Promise<void>;
delete(data: I{{pascalCase name}}): Promise<void>;
getById(id: string): Promise<I{{pascalCase name}}>;
getAll(): Promise<I{{pascalCase name}}[]>;
}
class {{pascalCase name}}ClientImpl implements I{{pascalCase name}}Client {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async create(data: I{{pascalCase name}}): Promise<void> {
const response = await fetch(`${this.baseUrl}/{{kebabCase name}}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async update(data: I{{pascalCase name}}): Promise<void> {
const response = await fetch(`${this.baseUrl}/{{kebabCase name}}/${data.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async delete(data: I{{pascalCase name}}): Promise<void> {
const response = await fetch(`${this.baseUrl}/{{kebabCase name}}/${data.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async getById(id: string): Promise<I{{pascalCase name}}> {
const response = await fetch(`${this.baseUrl}/{{kebabCase name}}/${id}`);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
async getAll(): Promise<I{{pascalCase name}}[]> {
const response = await fetch(`${this.baseUrl}/{{kebabCase name}}`);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
}
{{/each}}
export interface IAppClient {
{{#each r}}
{{camelCase name}}: I{{pascalCase name}}Client;
{{/each}}
}
export function createAppClient(baseUrl: string): IAppClient {
return {
{{#each r}}
{{camelCase name}}: new {{pascalCase name}}ClientImpl(baseUrl),
{{/each}}
};
}
Now, let's generate the code. The new src/client.ts
file now includes:
- Interfaces for the resource clients,
- Implementations of the resource clients,
- An
AppClient
interface, which includes all the resource clients, - A
createAppClient
function, which creates an instance of theAppClient
.
The generated src/client.ts
file
/** The user accounts in the system */
export interface IUserAccount {
/** Unique identifier of the UserAccount */
id: string;
/** The username of the user account */
username: string;
/** The email address of the user account */
email: string;
/** The flags of the user account */
flags: string;
}
export interface IUserAccountClient {
create(data: IUserAccount): Promise<void>;
update(data: IUserAccount): Promise<void>;
delete(data: IUserAccount): Promise<void>;
getById(id: string): Promise<IUserAccount>;
getAll(): Promise<IUserAccount[]>;
}
class UserAccountClientImpl implements IUserAccountClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async create(data: IUserAccount): Promise<void> {
const response = await fetch(`${this.baseUrl}/user-account`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async update(data: IUserAccount): Promise<void> {
const response = await fetch(`${this.baseUrl}/user-account/${data.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async delete(data: IUserAccount): Promise<void> {
const response = await fetch(`${this.baseUrl}/user-account/${data.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async getById(id: string): Promise<IUserAccount> {
const response = await fetch(`${this.baseUrl}/user-account/${id}`);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
async getAll(): Promise<IUserAccount[]> {
const response = await fetch(`${this.baseUrl}/user-account`);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
}
/** The products in the system */
export interface IProduct {
/** Unique identifier of the Product */
id: string;
/** The name of the product */
name: string;
/** The price of the product */
price: number;
/** The description of the product */
description: string;
}
export interface IProductClient {
create(data: IProduct): Promise<void>;
update(data: IProduct): Promise<void>;
delete(data: IProduct): Promise<void>;
getById(id: string): Promise<IProduct>;
getAll(): Promise<IProduct[]>;
}
class ProductClientImpl implements IProductClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async create(data: IProduct): Promise<void> {
const response = await fetch(`${this.baseUrl}/product`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async update(data: IProduct): Promise<void> {
const response = await fetch(`${this.baseUrl}/product/${data.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async delete(data: IProduct): Promise<void> {
const response = await fetch(`${this.baseUrl}/product/${data.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
async getById(id: string): Promise<IProduct> {
const response = await fetch(`${this.baseUrl}/product/${id}`);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
async getAll(): Promise<IProduct[]> {
const response = await fetch(`${this.baseUrl}/product`);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
}
export interface IAppClient {
userAccount: IUserAccountClient;
product: IProductClient;
}
export function createAppClient(baseUrl: string): IAppClient {
return {
userAccount: new UserAccountClientImpl(baseUrl),
product: new ProductClientImpl(baseUrl),
};
}
Generating the frontend
Now, we will use the generated client in a simple frontend application. Our goals are the following:
- We want to have a single page
- On this single page, we will have a table for each of our resources
- We will generate a form for creating data
- We will include a delete button in each row of the table
To do so, we will generate a file called src/App.tsx
in the project.
Before we can generate the frontend, we must refactor slightly our BasicType.pschema.yaml
schema. Let's add a new field, called tsDefault
to hold code for initializing the default value for a given type in TypeScript.
The BasicType.pschema.yaml
schema after modifications
id: BasicType
name: BasicType
description: A basic type for resource attributes
fields:
- name: go
type: string
description: The Go representation of the type
- name: ts
type: string
description: The TypeScript representation of the type
- name: tsDefault
type: string
description: The TypeScript default value of the type
We also need to modify our basic.pdata.yaml
file, so that the tsDefault
field is populated with the correct values.
The basic.pdata.yaml
file after modifications
id: basic
name: Basic Types
description: Basic types supported for resource attributes
schema: BasicType
objects:
- name: String
description: String
go: string
ts: string
tsDefault: '""'
- name: Int32
description: 32-bit integer
go: int32
ts: number
tsDefault: '0'
- name: Float32
description: 32-bit floating point number
go: float32
ts: number
tsDefault: '0.0'
- name: Boolean
description: Boolean
go: bool
ts: boolean
tsDefault: 'false'
Now let's create the .projor/template/app-tsx.ptemplate.mustache
file.
The app-tsx.ptemplate.mustache
template
{
"map": {
"r": "resources"
},
"filename": "src/App.tsx"
}
---
import { useEffect, useState } from 'react';
import {
{{#each r}}
I{{pascalCase name}},
{{/each}}
IAppClient,
createAppClient
} from "./client";
const appClient = createAppClient("http://localhost:8080");
{{#each r}}
function {{pascalCase name}}Table(
props: {
value: Array<I{{pascalCase name}}>
}
) {
const tableRows = props.value.map(
(data) => (
<tr key={data.id}>
<td>{data.id}</td>
{{#each fields}}
<td>{ data.{{snakeCase name}} }</td>
{{/each}}
</tr>
)
);
return <table border="1">
<thead>
<tr>
<th>Id</th>
{{#each fields}}
<th>{{capitalCase name}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{tableRows}
</tbody>
</table>;
}
function {{pascalCase name}}CreateForm(
props: {
onCreated: () => void
}
) {
const [id, setId] = useState("");
{{#each fields}}
const [{{snakeCase name}}, set{{pascalCase name}}] = useState(
{{{type.tsDefault}}}
);
{{/each}}
const clearForm = () => {
setId("");
{{#each fields}}
set{{pascalCase name}}(
{{{type.tsDefault}}}
);
{{/each}}
}
const onSave = async () => {
try {
await appClient.{{camelCase name}}.create({
id,
{{#each fields}}
{{#unless isList}}
{{snakeCase name}},
{{/unless}}
{{#if isList}}
{{snakeCase name}}: {{snakeCase name}}.split(","),
{{/if}}
{{/each}}
});
props.onCreated();
clearForm();
} catch (error) {
console.error(error);
}
};
return <form>
<div>
<label>
Id
</label>
<input
type="text"
value={id}
onChange={(e) => setId(e.target.value)}
/>
</div>
{{#each fields}}
<div>
<label>
{{capitalCase name}}
</label>
<input
type="text"
value={ {{snakeCase name}} }
onChange={(e) => set{{pascalCase name}}(e.target.value)}
/>
</div>
{{/each}}
<button type="button" onClick={onSave}>Save</button>
</form>;
}
{{/each}}
export function App() {
{{#each r}}
const [{{camelCase name}}s, set{{pascalCase name}}s] = useState<Array<I{{pascalCase name}}>>([]);
{{/each}}
{{#each r}}
const populate{{pascalCase name}}s = async () => {
try {
const data = await appClient.{{camelCase name}}.getAll();
set{{pascalCase name}}s(data || []);
} catch (error) {
console.error(error);
}
};
{{/each}}
useEffect(() => {
{{#each r}}
populate{{pascalCase name}}s();
{{/each}}
}, []);
return (
<div>
{{#each r}}
<h2>{{pascalCase name}}</h2>
<{{pascalCase name}}Table value={ {{camelCase name}}s } />
<{{pascalCase name}}CreateForm onCreated={populate{{pascalCase name}}s} />
{{/each}}
</div>
);
}
Now, all we have to do is to modify our src/index.tsx
file.
The src/index.tsx
file after modifications
import { createRoot } from 'react-dom/client';
import { App } from './App';
const rootEl = document.getElementById('root');
const root = createRoot(rootEl);
root.render(<App />);
Let's run the code generation now.
After that, we must:
- Run the backend application
- Run
npm run dev
to start the frontend application - Open the browser and navigate to
http://localhost:3000
You should be presented with a barebones frontend app, listing the resources and allowing you to create new entries.
WARNING
This example generated frontend is far from perfect.
As you may see, lists require special attention in most cases (the #if isList
and #unless isList
blocks). Numbers also require special handling, and in this example the form for creating products does not work because of that.
Generating a README
Our next goal is to create documentation for our application.
We will create a README.md
file, which will be presented to anyone opening the application's repository. We will use the code generator to create (and update) the contents of this file.
Let's create the .projor/template/readme-md.ptemplate.mustache
file.
The readme-md.ptemplate.mustache
template
{
"map": {
"r": "resources"
},
"filename": "README.md"
}
---
# ProJor Go Example
This repository contains the source code for the ProJor Go Example application. The repository uses [ProJor](https://docs.siocode.hu/projor), the model-based code generator to maintain most of the source code. See in the `.projor/` directory to examine the schema, model, and templates used to generate the code.
## Building
* First, you must build the backend by running `go build`
* Then, you must build the frontend by running `npm install` and `npm run build`
## Running
* First, start the backend by executing the built binary file (called `projor-go-example` or `projor-go-example.exe` on Windows)
* Then, start the frontend by running `npm run dev`
* Open your browser and navigate to `http://localhost:3000`
* Use [Postman](https://www.postman.com/) to test the backend API (running on `http://localhost:8080`)
## Documentation
{{#each r}}
### {{{capitalCase name}}}
{{{description}}}
Fields:
* `id` : `string` - Unique identifier of the {{pascalCase name}}
{{#each fields}}
* `{{snakeCase name}}` : `{{type.name}}` - {{{description}}}
{{/each}}
{{/each}}
Let's generate the code, and examine the README.md
file.
The generated README.md
file
# ProJor Go Example
This repository contains the source code for the ProJor Go Example application. The repository uses [ProJor](https://docs.siocode.hu/projor), the model-based code generator to maintain most of the source code. See in the `.projor/` directory to examine the schema, model, and templates used to generate the code.
This example is documented [here](https://docs.siocode.hu/full-examples/go).
## Building
* First, you must build the backend by running `go build`
* Then, you must build the frontend by running `npm install` and `npm run build`
## Running
* First, start the backend by executing the built binary file (called `projor-go-example` or `projor-go-example.exe` on Windows)
* Then, start the frontend by running `npm run dev`
* Open your browser and navigate to `http://localhost:3000`
* Use [Postman](https://www.postman.com/) to test the backend API (running on `http://localhost:8080`)
## Documentation
### User Account
The user accounts in the system
Fields:
* `id` : `string` - Unique identifier of the UserAccount
* `username` : `String` - The username of the user account
* `email` : `String` - The email address of the user account
* `flags` : `String` - The flags of the user account
### Product
The products in the system
Fields:
* `id` : `string` - Unique identifier of the Product
* `name` : `String` - The name of the product
* `price` : `Float32` - The price of the product
* `description` : `String` - The description of the product