Skip to content

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
yaml
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
yaml
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
yaml
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
yaml
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
yaml
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

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 execute Projor: Generate code.
  • If using CLI, run projor generate.
What you should get ...

The code generation results in two files being created.

user-account.go

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

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

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

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.

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 structs.

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
go
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
json
{
    "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
json
{
    "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
html
<!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
tsx
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.

bash
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
ts
/** 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 the AppClient.
The generated src/client.ts file
ts
/** 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
yaml
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
yaml
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
tsx
{
    "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
tsx
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.

Frontend application

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
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.

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

README.md file