In this tutorial, you will create an order
resource for a Terraform provider that interacts with the API of a fictional coffee-shop application, HashiCups. Then, you will use the schema to create a new order and populate the order. To do this, you will:
- Define
order
resource.
You will add a scaffold that defines an empty schema and functions to create, read, update and delete orders. - Define
order
schema.
The schema contains fields used to create a new order and retrieved by the read functionality. - Add create functionality.
The create function invokes aPOST
request to/orders
to create a new order. - Add read functionality.
The read function invokes aGET
request to/orders/{orderID}
to retrieve the order's data. The orders schema contains a nested object, therefore you will use multiple flattening functions to map the response to theorder
schema. - Add
order
resource to the provider schema.
This allows you to use the resource in your configuration.
»Prerequisites
To follow this tutorial, you need:
- a Golang 1.13+ installed and configured.
- the Terraform 0.14+ CLI installed locally. The Terraform binaries are located here.
- Docker and Docker Compose to run an instance of HashiCups locally.
Navigate to your terraform-provider-hashicups
directory. Then, checkout the debug-tf-providers
branch. This step is optional but recommended to insure that you've accurately completed the previous steps.
$ git checkout debug-tf-providers
Your directory should have the following structure.
$ tree -L 2
.
├── Makefile
├── README.md
├── docker_compose
│ ├── conf.json
│ └── docker-compose.yml
├── examples
│ ├── coffee
│ ├── main.tf
├── go.mod
├── go.sum
├── hashicups
│ ├── data_source_coffee.go
│ ├── data_source_order.go
│ └── provider.go
├── main.go
└── vendor
If you’re stuck, refer to the implement-create
branch to see the changes implemented in this tutorial.
»Define order
resource
Add the following code snippet to a new file named resource_order.go
in the hashicups
directory. As a general convention, Terraform providers put each resource in their own file, named after the resource, prefixed with resource_
.
package hashicups
import (
"context"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func resourceOrder() *schema.Resource {
return &schema.Resource{
CreateContext: resourceOrderCreate,
ReadContext: resourceOrderRead,
UpdateContext: resourceOrderUpdate,
DeleteContext: resourceOrderDelete,
Schema: map[string]*schema.Schema{},
}
}
func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
// Warning or errors can be collected in a slice type
var diags diag.Diagnostics
return diags
}
func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
// Warning or errors can be collected in a slice type
var diags diag.Diagnostics
return diags
}
func resourceOrderUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
return resourceOrderRead(ctx, d, m)
}
func resourceOrderDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
// Warning or errors can be collected in a slice type
var diags diag.Diagnostics
return diags
}
This serves as a scaffold for the order
resource. Notice, the order resource defines create, read, update and delete (CRUD) functionality. In this tutorial, you will only implement create and read to create and read a new order
resource.
»Define order
schema
To create a HashiCups order, you would send a POST request to the /orders
endpoint with a list of OrderItems
, containing the coffee object and its respective quantity.
$ curl -X POST -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders -d '[{"coffee": { "id":1 }, "quantity":4}, {"coffee": { "id":3 }, "quantity":3}]'
Replace the line Schema: map[string]*schema.Schema{},
in your resourceOrder
function with the following schema. The order
resource schema should resemble the request body.
Schema: map[string]*schema.Schema{
"items": &schema.Schema{
Type: schema.TypeList,
Required: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"coffee": &schema.Schema{
Type: schema.TypeList,
MaxItems: 1,
Required: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"id": &schema.Schema{
Type: schema.TypeInt,
Required: true,
},
"name": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"teaser": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"description": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"price": &schema.Schema{
Type: schema.TypeInt,
Computed: true,
},
"image": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
},
},
},
"quantity": &schema.Schema{
Type: schema.TypeInt,
Required: true,
},
},
},
},
},
This order
schema should be similar to the order
data resource defined in the previous Implement Complex Read tutorial with two significant changes.
- The top level
id
attribute is missing in theorder
resource, but present in theorder
data resource. Unlike theorder
data source, you don't know the order ID ahead of time. When HashiCups provider invokes the HashiCups API, the API generates an order ID which is then assigned as the resource's ID by the HashiCups provider. items
is arequired
field, not a computed one. This is because theOrderItems
need to be defined to create an order.
There are multiple ways to nest maps.
- The first (see
coffee
object) is to define the nested object as anschema.TypeList
with 1 item. This is currently the closest way to emulate a nested object. - The second way is to use a
schema.TypeMap
. This method may be preferable if you only require a key value map of primitive types. However, you should use a validation function to enforce required keys.
Notice that the coffee object is represented as a schema.TypeList
of one item. This method was selected because it closely matches the coffee object returned in the response.
»Implement create
Now that you have defined the order
resource schema, replace the resourceOrderCreate
function in resource_order.go
with the following code snippet. This function will create a new HashiCups order and Terraform resource.
func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
c := m.(*hc.Client)
// Warning or errors can be collected in a slice type
var diags diag.Diagnostics
items := d.Get("items").([]interface{})
ois := []hc.OrderItem{}
for _, item := range items {
i := item.(map[string]interface{})
co := i["coffee"].([]interface{})[0]
coffee := co.(map[string]interface{})
oi := hc.OrderItem{
Coffee: hc.Coffee{
ID: coffee["id"].(int),
},
Quantity: i["quantity"].(int),
}
ois = append(ois, oi)
}
o, err := c.CreateOrder(ois)
if err != nil {
return diag.FromErr(err)
}
d.SetId(strconv.Itoa(o.ID))
return diags
}
Since this uses strconv
to convert the ID into a string, remember to import the strconv
library. Remember to also import the HashiCups API client library.
import (
"context"
+ "strconv"
+ hc "github.com/hashicorp-demoapp/hashicups-client-go"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
Notice the m
(meta) input parameter contains the HashiCups API Client set by the ConfigureContextFunc
defined above. If an unauthenticated API Client is provided (no username/password), this function will fail and return an error message.
The function retrieves the order attributes using d.Get("items")
— order items are located in items
as defined in the schema — and typecasts it to a []interface{}
. Then, the function transforms the items into a slice of OrderItem
, a struct required by the HashiCups API client to create an order.
Finally, upon successful order creation, SetID
sets the resource ID to the order ID. The resource ID must be a non-blank string that can be used to read the resource again. If no ID is set, Terraform assumes the resource was not created successfully; as a result, no state will be saved for that resource.
»Implement read
Now that you have implemented create, you must implement read to retrieve the resource current state. This will be run during the plan
process to determine whether the resource needs to be updated.
Replace the resourceOrderRead
function in resource_order.go
with the following code snippet.
func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
c := m.(*hc.Client)
// Warning or errors can be collected in a slice type
var diags diag.Diagnostics
orderID := d.Id()
order, err := c.GetOrder(orderID)
if err != nil {
return diag.FromErr(err)
}
orderItems := flattenOrderItems(&order.Items)
if err := d.Set("items", orderItems); err != nil {
return diag.FromErr(err)
}
return diags
}
This function should be similar to the dataSourceOrderRead
function in data_resource_order.go
with two significant change.
- The
order
resource's read function (resourceOrderRead
) usesd.ID()
as the order ID. Theorder
data resource's read function (dataSourceOrderRead
) uses an order ID provider by the user. - The function uses
flattenOrderItems
instead offlattenOrderItemsData
because theorder
resource and theorder
data source uses different schemas. You will define theflattenOrderItems
in the next section.
»Add flattening functions
The API Client's GetOrder
function returns an order object that needs to be "flattened" so the provider can accurately map it to the order schema
. An order consists of an order ID and a list of coffee objects and their respective quantities.
As a result, it must go through two flattening functions:
- The first flattening function populates the list of coffee objects and their quantities
- The second flattening function populates the actual coffee object itself.
Add the following function to your resource_order.go
file. This is the first flattening function to return a list of order items. The flattenOrderItems
function takes an *[]hc.OrderItem
as orderItems
. If orderItems
is not nil, it will iterate through the slice and map its values into a map[string]interface{}
.
func flattenOrderItems(orderItems *[]hc.OrderItem) []interface{} {
if orderItems != nil {
ois := make([]interface{}, len(*orderItems), len(*orderItems))
for i, orderItem := range *orderItems {
oi := make(map[string]interface{})
oi["coffee"] = flattenCoffee(orderItem.Coffee)
oi["quantity"] = orderItem.Quantity
ois[i] = oi
}
return ois
}
return make([]interface{}, 0)
}
Then, add the second flattening function, flattenCoffee
, to your resource_order.go
file. This is called in the first flattening function. It takes a hc.Coffee
and turns a slice with a single object. Notice how this mirrors the coffee schema — a schema.TypeList
with a maximum of one item.
func flattenCoffee(coffee hc.Coffee) []interface{} {
c := make(map[string]interface{})
c["id"] = coffee.ID
c["name"] = coffee.Name
c["teaser"] = coffee.Teaser
c["description"] = coffee.Description
c["price"] = coffee.Price
c["image"] = coffee.Image
return []interface{}{c}
}
»Add read function to create function
Finally, add resourceOrderRead
to the bottom of your resourceOrderCreate
function. This will populate the Terraform state to its current state after the resource creation.
func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
// ...
d.SetId(strconv.Itoa(o.ID))
+ resourceOrderRead(ctx, d, m)
return diags
}
»Add order
resource to provider
Now that you’ve defined the order
resource, you can add it to your provider.
In your provider.go
file, add the order
resource to the ResourceMap. Resources and data sources names must follow the <provider>_<resource_name>
convention.
// Provider -
func Provider() *schema.Provider {
return &schema.Provider{
Schema: map[string]*schema.Schema{
"username": &schema.Schema{
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_USERNAME", nil),
},
"password": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Sensitive: true,
DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_PASSWORD", nil),
},
},
ResourcesMap: map[string]*schema.Resource{
+ "hashicups_order": resourceOrder(),
},
DataSourcesMap: map[string]*schema.Resource{
"hashicups_coffees": dataSourceCoffees(),
"hashicups_order": dataSourceOrder(),
},
ConfigureContextFunc: providerConfigure,
}
}
»Test the provider
Now that you’ve created the order
resource with create and read capabilities, verify that it works.
First, navigate to the terraform-provider-hashicups
root directory.
Then, build the binary and move it into your user Terraform plugins directory. This allows you to sideload and test your custom providers.
$ make install
go build -o terraform-provider-hashicups
mv terraform-provider-hashicups ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.2/darwin_amd64
Navigate to the examples
directory. This contains a sample Terraform configuration for the Terraform HashiCups provider.
$ cd examples
Add the following Terraform configuration to main.tf
.
resource "hashicups_order" "edu" {
items {
coffee {
id = 3
}
quantity = 2
}
items {
coffee {
id = 2
}
quantity = 2
}
}
output "edu_order" {
value = hashicups_order.edu
}
Then, initialize your workspace to refresh your HashiCups provider then. This should create an order and return its values in your output.
$ terraform init && terraform apply --auto-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
»Next steps
Congratulations! You have created the order
resource with create and read capabilities.
If you were stuck during this tutorial, checkout the implement-create
branch to see the changes implemented in this tutorial.