A Document Driven API to Go

DevDay Caserta

21 April 2017

Luca Corbo

ScientiaMobile

Outline

We'll go through the life cycle of a simple but fully functional web API:

Design & Document

API Description Languages

API Description Languages are formal languages designed to provide a structured description of a web API that is useful both to a human and for automated machine processing. *

Additional advantages:

OpenAPI Initiative (Swagger) - https://openapis.org

OpenAPI Specification

/pet/{petId}:
    get:
      summary: "Find pet by ID"
      description: "Returns a single pet"
      operationId: "getPetById"
      produces:
      - "application/json"
      parameters:
      - name: "petId"
        in: "path"
        description: "ID of pet to return"
        required: true
        type: "integer"
        format: "int64"
      responses:
        200:
          description: "successful operation"
          schema:
            $ref: "#/definitions/Pet"
        400:
          description: "Invalid ID supplied"
        404:
          description: "Pet not found"

OpenAPI Specification - Objects

Pet:
    type: "object"
    required:
    - "name"
    - "photoUrls"
    properties:
      id:
        type: "integer"
        format: "int64"
      category:
        $ref: "#/definitions/Category"
      name:
        type: "string"
      status:
        type: "string"
        description: "pet status in the store"
        enum:
        - "available"
        - "pending"
        - "sold"

OpenAPI - Swagger Editor

OpenAPI - Swagger HTML output

OpenAPI - Swagger endpoint details

OpenAPI - Swagger endpoint responses

API Blueprint - https://apiblueprint.org

The application

A TODO application endpoints

Get all tasks

GET /tasks

Add a task

POST /tasks

Get a task

GET /tasks/{id}

Edit a task

PUT /tasks/{id}

Delete a task

DELETE /tasks/{id}

API Blueprint - Metadata

FORMAT: 1A
HOST: http://todo.local/api

# Another TODO

Another TODO API is that, another TODO API.

API Blueprint - Resources

# Group Tasks

Resources related to the tasks in the API.

## Tasks Collection [/tasks]

### List All Tasks [GET]

+ Response 200 (application/json)

    [
        {
            "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
            "description": "Buy milk",
            "completed": false,
        },
        {
            "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
            "description": "Buy milk",
            "completed": false,
        },
    ]

API Blueprint - Data Structures

## Data Structures

### Task

+ id: `6ba7b810-9dad-11d1-80b4-00c04fd430c8` (string, required) - Task's ID
+ description: `Buy milk` (string, required) - Task's description
+ completed: false (boolean, required) - Done status
+ createdAt: `2017-04-09T16:30:42+00:00` (string, required) - Task's created date
+ updatedAt: `2017-04-09T16:30:42+00:00` (string, required) - Task's update date

API Blueprint - Resources with Data Structures

# Group Tasks

Resources related to the tasks in the API.

## Tasks Collection [/tasks]

### List All Tasks [GET]

+ Response 200 (application/json)

    + Attributes (array [Task])

API Blueprint - Create a task

## Tasks Collection [/tasks]

...

### Create a New Task [POST]

+ Attributes
    + description: `Buy milk` (string, required) - Task description
    + completed: false (boolean, required) - Done status

+ Request (application/json)

    {
      "description": "Buy milk",
      "completed": false
    }

+ Response 201 (application/json)

    + Attributes (Task)

API Blueprint - Error responses

### Create a New Task [POST]

+ Attributes
    + description: `Buy milk` (string, required) - Task description
    + completed: false (boolean, required) - Done status

+ Request (application/json)

    {
      "description": "Buy milk",
      "completed": false
    }

+ Response 201 (application/json)

    + Attributes (Task)

+ Response 422 (application/json)

    {
      "error": "Unprocessable entity",
      "message": "an error message"
    }

API Blueprint - Get a Task

## Task [/tasks/{taskId}]

+ Parameters

  + taskId: `6ba7b810-9dad-11d1-80b4-00c04fd430c8` (string, required) - ID if the task

### View a Task [GET]

+ Response 200 (application/json)

    + Attributes (Task)

+ Response 404

API Blueprint - Edit a Task

## Task [/tasks/{taskId}]

...

### Edit a Task [PUT]

+ Attributes
    + description: `Buy milk` (string, required) - Task description
    + completed: true (boolean, required) - Done status

+ Request (application/json)

        {
            "description": "Buy milk",
            "completed": true
        }

+ Response 200 (application/json)

    + Attributes (Task)

API Blueprint - Delete a Task

## Task [/tasks/{taskId}]

...

### Delete a Task [DELETE]

+ Response 204

Great, and now ?

Generate an HTML version

Aglio: an API Blueprint renderer that supports multiple themes and outputs static HTML that can be served by any web host *

Using docker:

docker run --rm -v $PWD:/data lucor/aglio \
  -i docs/api-v1.apib \
  -o public/docs/index.html \
  --theme-full-width

Using the executable:

npm install -g aglio 

aglio -i docs/api-v1.apib \
      -o public/docs/index.html \
      --theme-full-width

The static HTML version!

The static HTML version - some details

Mock server

Snowboard an API Blueprint toolkit in Go *

docker run -it -v $PWD:/doc -p 8087:8087 bukalapak/snowboard mock -b :8087 docs/api-v1.apib

Mock server is ready. Use :8087
Available Routes:
GET      200    /tasks
POST     201    /tasks
POST     422    /tasks
GET      200    /tasks/:taskId
GET      404    /tasks/:taskId
PUT      200    /tasks/:taskId
DELETE   204    /tasks/:taskId

Test the Mock server

curl http://127.0.0.1:8087/tasks

[
  {
    "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "description": "Buy milk",
    "completed": false,
    "createdAt": "2017-04-09T16:30:42+00:00",
    "updatedAt": "2017-04-09T16:30:42+00:00"
  }
]

Test the Mock server: multiple responses

curl -v -X POST -H "X-Status-Code: 422" http://127.0.0.1:8087/tasks

*   Trying 127.0.0.1...
* Connected to 127.0.0.1 (127.0.0.1) port 8087 (#0)
> POST /tasks HTTP/1.1
> Host: 127.0.0.1:8087
> User-Agent: curl/7.47.0
> Accept: */*
> X-Status-Code: 422

< HTTP/1.1 422 Unprocessable Entity
< Content-Type: application/json
< Date: Sat, 07 Apr 2018 18:42:05 GMT
< Content-Length: 71

{
  "error": "Unprocessable entity",
  "message": "an error message"
}

Develop and Test in Golang

Project structure

tree $GOPATH/src/github.com/lucor/document-driven-api-to-go
.
├── api.go
├── api_test.go
├── docs
│   └── api-v1.apib
├── Gopkg.lock
├── Gopkg.toml
├── public
│   └── docs
│       └── index.html
└── vendor
    └── ...

To test and run our application:

go test -v                  //runs tests
go run api.go               //compiles and runs the main package

Dependency manager tool - Dep

Dep is a tool for managing dependencies for Go projects

Examples:

dep init                               set up a new project
dep ensure                             install the project's dependencies
dep ensure -update                     update the locked versions of all dependencies
dep ensure -add github.com/pkg/errors  add a dependency to the project

Dep website: golang.github.io/dep/

Gopher by Ashley McNamara

Gopkg.toml

# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#

[[constraint]]
  name = "github.com/gorilla/mux"
  version = "1.6.1"

[[constraint]]
  name = "github.com/satori/go.uuid"
  version = "1.2.0"

Gopkg.lock

# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.


[[projects]]
  name = "github.com/gorilla/context"
  packages = ["."]
  revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
  version = "v1.1"

[[projects]]
  name = "github.com/gorilla/mux"
  packages = ["."]
  revision = "53c1911da2b537f792e7cafcb446b05ffe33b996"
  version = "v1.6.1"

[[projects]]
  name = "github.com/satori/go.uuid"
  packages = ["."]
  revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
  version = "v1.2.0"

api.go - Serve the API documentation

package main

import (  
  "net/http"  
  "github.com/gorilla/mux"
)

func main() {
  // Create the router
  router := mux.NewRouter()

  // Serve static files: this will serve our docs under http://localhost:9999/static/docs/
  router.PathPrefix("/static/").Handler(
    http.StripPrefix("/static/", http.FileServer(http.Dir("public"))),
  )

  log.Fatal(http.ListenAndServe(":9999", router))
}

api.go - Define the routes

// storage is a map used to simulate our storage system
var storage map[string]Task

func main() {

  // Initialize the storage map
  storage = make(map[string]Task)
  
  // Create the router
  router := mux.NewRouter()

  // Task endpoints
  router.HandleFunc("/tasks", TasksHandler).Methods(http.MethodGet)
  router.HandleFunc("/tasks", CreateTaskHandler).Methods(http.MethodPost)
  router.HandleFunc("/task/{taskID}", GetTaskHandler).Methods(http.MethodGet)
  router.HandleFunc("/task/{taskID}", EditTaskHandler).Methods(http.MethodPut)
  router.HandleFunc("/task/{taskID}", DeleteTaskHandler).Methods(http.MethodDelete)
  ...

api.go - Define the structs

// Task represents a Task
type Task struct {
  ID          string `json:"id"`
  Description string `json:"description"`
  Completed   bool   `json:"completed"`
  CreatedAt   string `json:"createdAt"`
  UpdatedAt   string `json:"updatedAt"`
}

// TaskRequestOptions represents the options used to create or edit Task
type TaskRequestOptions struct {
  Description string `json:"description"`
  Completed   bool   `json:"completed"`
}

// ErrorResponse represents an error response
type ErrorResponse struct {
  Message string `json:"message"`
  Error   string `json:"error"`
}

api.go - Tasks Handler

func TasksHandler(w http.ResponseWriter, r *http.Request) {

    tasks := []Task{}

    for _, task := range storage {
        tasks = append(tasks, task)
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    body, _ := json.Marshal(tasks)
    w.Write(body)
}

Testing tool - Golang Package Testing

go test -v
=== RUN   TestTasksHandler
--- PASS: TestTasksHandler (0.00s)
=== RUN   TestCreateTaskHandler
--- PASS: TestCreateTaskHandler (0.00s)
=== RUN   TestGetTaskHandler
--- PASS: TestGetTaskHandler (0.00s)
=== RUN   TestEditTaskHandler
--- PASS: TestEditTaskHandler (0.00s)
=== RUN   TestDeleteTask
--- PASS: TestDeleteTask (0.00s)
PASS
ok      github.com/lucor/document-driven-api-to-go    0.003s

Testing the handlers

import (
  "net/http/httptest"
  "testing"
)

func TestTasksHandler(t *testing.T) {
  // setup the test environment
  setup()

  // create an HTTP request
  req := httptest.NewRequest(http.MethodGet, "/tasks", nil)

  // create an HTTP response recorder
  rr := httptest.NewRecorder()

  // invoke the handler
  TasksHandler(rr, req)

  // assertions using the response recorder
  if status := rr.Code; status != http.StatusOK {
    t.Errorf("wrong status code: got %v want %d", status, http.StatusOK)
  }
  ...

api_test.go - The setup function

func setup() {
    // Initialize the tasks map
    storage = make(map[string]Task)

    // Add a fake task
    now := time.Now().Format(time.RFC3339)
    storage["6ba7b810-9dad-11d1-80b4-00c04fd430c8"] = Task{
        ID:          "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
        Description: "Buy milk",
        Completed:   false,
        CreatedAt:   now,
        UpdatedAt:   now,
    }
}

api_test.go - Tasks Handler Test Assertions

    tasks := []Task{}

    err := json.NewDecoder(rr.Body).Decode(&tasks)

    if err != nil {
        t.Errorf("unable to decode body: got %s", rr.Body.String())
    }

    if len(tasks) != 1 {
        t.Errorf("unexpected len: got %d want %d", len(tasks), 1)
    }

    task := tasks[0]

    if task.ID != "6ba7b810-9dad-11d1-80b4-00c04fd430c8" {
        t.Errorf("unexpected ID: got %v want %v", task.ID, "6ba7b810-9dad-11d1-80b4-00c04fd430c8")
    }

api.go - Create Task Handler (1/2)

func CreateTaskHandler(w http.ResponseWriter, r *http.Request) {

    // Parse and validate request
    options := TaskRequestOptions{}
    err := json.NewDecoder(r.Body).Decode(&options)
    if err != nil {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusBadRequest)
        body, _ := json.Marshal(ErrorResponse{
            Message: "unable to parse the body request",
            Error:   err.Error(),
        })
        w.Write(body)
        return
    }

api.go - Create Task Handler (2/2)

    // Create the Task
    now := time.Now().Format(time.RFC3339)
    task := Task{
        ID:          uuid.NewV4().String(),
        Description: options.Description,
        Completed:   options.Completed,
        CreatedAt:   now,
        UpdatedAt:   now,
    }

    // Persist the Task
    storage[task.ID] = task

    // Return the response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    body, _ := json.Marshal(task)
    w.Write(body)
}

api_test.go - Create Task Handler Test (1/2)

func TestCreateTaskHandler(t *testing.T) {

    setup()

    req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(`
        {
            "description": "Buy bread",
            "completed": false
        }
`))
    rr := httptest.NewRecorder()

    CreateTaskHandler(rr, req)

api_test.go - Create Task Handler Test (2/2)

    if status := rr.Code; status != http.StatusOK {
        t.Errorf("wrong status code: got %v want %d", status, http.StatusOK)
    }
    tasks := []Task{}

    err := json.NewDecoder(rr.Body).Decode(&tasks)

    if err != nil {
        t.Errorf("unable to decode body: got %s", rr.Body.String())
    }

    if len(tasks) != 1 {
        t.Errorf("unexpected len: got %d want %d", len(tasks), 1)
    }

    task := tasks[0]

    if task.ID != "6ba7b810-9dad-11d1-80b4-00c04fd430c8" {
        t.Errorf("unexpected ID: got %v want %v", task.ID, "6ba7b810-9dad-11d1-80b4-00c04fd430c8")
    }

}

api.go - Delete Task Handler

func DeleteTaskHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    taskID := vars["taskID"]

    delete(storage, taskID)

    w.WriteHeader(http.StatusNoContent)
}

api.go - Delete Task Handler Test

func TestDeleteTaskHandler(t *testing.T) {
    setup()

    req := httptest.NewRequest(http.MethodDelete, "/tasks/{taskId}", nil)

    // sets the URL variables for the given request, to be accessed via mux.Vars for testing route behaviour.
    req = mux.SetURLVars(req, map[string]string{"taskID": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"})

    rr := httptest.NewRecorder()

    DeleteTaskHandler(rr, req)

    if status := rr.Code; status != http.StatusNoContent {
        t.Errorf("wrong status code: got %v want %d", status, http.StatusNoContent)
    }
}

Thank you

Luca Corbo

ScientiaMobile