Creating a Merged Terraform Entity

Creating a merged Terraform entity involves combining data from separate API endpoints into a single Terraform resource. This process allows Terraform to manage complex entities that span multiple API calls for their lifecycle operations—create, read, update, and delete.

Example Scenario: Merging Resource Entities

Consider a scenario where managing a drink resource requires setting a visibility attribute post-creation using separate API endpoints:

  1. Create the drink: Invoke POST /drink to create the drink entity.
  2. Set visibility: Follow with POST /drink/{id}/visibility to configure visibility.

Step 1: Define API Endpoints

Identify the API endpoints involved in the operation. For instance, creating a drink and setting its visibility involves two distinct endpoints:


/drinks:
post:
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
required: [name]
responses:
"200":
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: string
required: [id]
required: [data]
/drink/{id}/visibility:
post:
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
visibility:
type: string
enum:
- public
- private
responses:
"202":
description: OK

Step 2: Annotate for Execution Order

Mark both operations with annotations. For the operation requiring the id parameter, assign an order property value greater than the first operation to reflect its dependency on the id attribute.


/drinks:
post:
x-speakeasy-entity-operation: Drink#create
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
required: [name]
responses:
"200":
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: string
required: [id]
required: [data]
/drink/{id}/visibility:
post:
x-speakeasy-entity-operation: Drink#create#2
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
visibility:
type: string
enum:
- public
- private
responses:
"202":
description: OK

Step 3: Configure Hoisting for Response Unwrapping

Use x-speakeasy-entity annotations to simplify response handling by hoisting, avoiding nested data wrapping.


/drinks:
post:
x-speakeasy-entity-operation: Drink#create
requestBody:
required: true
content:
application/json:
schema:
x-speakeasy-entity: Drink
type: object
properties:
name:
type: string
required: [name]
responses:
"200":
content:
application/json:
schema:
type: object
properties:
data:
type: object
x-speakeasy-entity: Drink
properties:
id:
type: string
required: [id]
required: [data]
/drink/{id}/visibility:
post:
x-speakeasy-entity-operation: Drink#create#2
requestBody:
required: true
content:
application/json:
schema:
type: object
x-speakeasy-entity: Drink
properties:
visibility:
type: string
enum:
- public
- private
responses:
"202":
description: OK

Step 1: Define API Endpoints

Identify the API endpoints involved in the operation. For instance, creating a drink and setting its visibility involves two distinct endpoints:

Step 2: Annotate for Execution Order

Mark both operations with annotations. For the operation requiring the id parameter, assign an order property value greater than the first operation to reflect its dependency on the id attribute.

Step 3: Configure Hoisting for Response Unwrapping

Use x-speakeasy-entity annotations to simplify response handling by hoisting, avoiding nested data wrapping.


/drinks:
post:
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
required: [name]
responses:
"200":
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: string
required: [id]
required: [data]
/drink/{id}/visibility:
post:
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
visibility:
type: string
enum:
- public
- private
responses:
"202":
description: OK

Advanced Example Step-by-Step

When an x-speakeasy-entity-operation is defined, the request body, parameters, and response bodies of CREATE and READ operations are considered the root of the Terraform Type Schema.

Step 1:

Adding a x-speakeasy-entity-operation: Drink#create annotation marks the POST /drinks operation as CREATING a drink resource.

openapi.yaml

/drinks:
post:
x-speakeasy-entity-operation: Drink#create
parameters:
- name: type
in: query
description: The type of drink
required: false
deprecated: true
schema:
$ref: "#/components/schemas/DrinkType"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Drink"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Drink"
"5XX":
$ref: "#/components/responses/APIError"
default:
$ref: "#/components/responses/UnknownError"
components:
schemas:
Drink:
type: object
properties:
name:
description: The name of the drink.
type: string
examples:
- Old Fashioned
- Manhattan
- Negroni
type:
$ref: "#/components/schemas/DrinkType"
price:
description: The price of one unit of the drink in US cents.
type: number
examples:
- 1000 # $10.00
- 1200 # $12.00
- 1500 # $15.00
required:
- name
- price
DrinkType:
description: The type of drink.
type: string
enum:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other

Step 2:

Parameters, Request Bodies, and Response Bodies (associated with a 2XX status code) are each considered roots of the Terraform Type Schema

openapi.yaml

/drinks:
post:
x-speakeasy-entity-operation: Drink#create
parameters:
- name: type
in: query
description: The type of drink
required: false
deprecated: true
schema:
$ref: "#/components/schemas/DrinkType"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Drink"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Drink"
"5XX":
$ref: "#/components/responses/APIError"
default:
$ref: "#/components/responses/UnknownError"
components:
schemas:
Drink:
type: object
properties:
name:
description: The name of the drink.
type: string
examples:
- Old Fashioned
- Manhattan
- Negroni
type:
$ref: "#/components/schemas/DrinkType"
price:
description: The price of one unit of the drink in US cents.
type: number
examples:
- 1000 # $10.00
- 1200 # $12.00
- 1500 # $15.00
required:
- name
- price
DrinkType:
description: The type of drink.
type: string
enum:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other

Step 3

The Terraform Type Schema merges all 3 of these together, and inferring links between the Operation and the Attributes. Note that similarly named attributes are merged together, and that the DrinkType attribute is inferred to be a DrinkType enum, rather than a string.

openapi.yaml
derived.yaml

- create.parameters:
type:
from: "paths["/drinks"].post.parameters[0]"
type: enum
enumValues:
type: string
values:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other
- create.requestBody:
name:
from: "components.schemas.Drink.properties.name"
description: The name of the drink.
type: string
examples:
- Old Fashioned
- Manhattan
- Negroni
drinkType:
from: "components.schemas.Drink.properties.type"
type: string
enum:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other
price:
from: "components.schemas.Drink.properties.price]"
type: number
- create.successResponseBody:
name:
from: "components.schemas.Drink.properties.name"
description: The name of the drink.
type: string
examples:
- Old Fashioned
- Manhattan
- Negroni
drinkType:
from: "components.schemas.Drink.properties.type"
type: string
enum:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other
price:
from: "components.schemas.Drink.properties.price]"
type: number

Step 4

These attributes are then merged together. If any properties conflict in type, an error is raised.

openapi.yaml
derived.yaml

- create.requestShard:
type:
from: "paths["/drinks"].post.parameters[0]"
type: enum
enumValues:
type: string
values:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other
name:
from: "components.schemas.Drink.properties.name"
description: The name of the drink.
type: string
examples:
- Old Fashioned
- Manhattan
- Negroni
drinkType:
from: "components.schemas.Drink.properties.type"
type: string
enum:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other
price:
from: "components.schemas.Drink.properties.price]"
type: number
- create.responseShard:
name:
from: "components.schemas.Drink.properties.name"
description: The name of the drink.
type: string
examples:
- Old Fashioned
- Manhattan
- Negroni
drinkType:
from: "components.schemas.Drink.properties.type"
type: string
enum:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other
price:
from: "components.schemas.Drink.properties.price]"
type: number

Step 5

First, the type is taken from the OpenAPI type. It is Optional: true because it is not required: true or nullable: true. The description is taken from the OpenAPI description. The examples will be used to generate an example for the documentation

openapi.yaml
derived.yaml
drink_resource.go

func (r *DrinkResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Drink Resource",
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Optional: true,
Description: `The type of drink.`,
},
},
}
}

Step 6

Second, the drinkType is taken from the request and response bodies. It's converted to snake case as drink_type to follow terraform best-practices.

It is Optional: true because it was not a member of the OpenAPI requiredProperties. It is Computed: true because even if not defined, the API is defined to (optionally) return a value for it.

A plan validator is added with each of the enum values. This will be used to validate the plan at plan-time.

openapi.yaml
derived.yaml
drink_resource.go

func (r *DrinkResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Drink Resource",
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Optional: true,
Description: `The type of drink.`,
},
"drink_type": schema.StringAttribute{
Optional: true,
Computed: true
Description: `The type of drink.`,
stringvalidator.OneOf(
"cocktail",
"non-alcoholic",
"beer",
"wine",
"spirit",
"other",
),
},
},
}
}

Step 7

The other parameters are also pulled in from the request body. Both of them are required, with their type being derived from the equivalent Terraform primitive to their OpenAPI type.

openapi.yaml
derived.yaml
drink_resource.go

func (r *DrinkResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Drink Resource",
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Optional: true,
Description: `The type of drink.`,
},
"drink_type": schema.StringAttribute{
Optional: true,
Computed: true
Description: `The type of drink.`,
stringvalidator.OneOf(
"cocktail",
"non-alcoholic",
"beer",
"wine",
"spirit",
"other",
),
},
"name": schema.Int64Attribute{
Required: true,
Description: `The name of the drink.`,
},
"price": schema.StringAttribute{
Required: true,
Description: `The price of one unit of the drink in US cents.`,
},
},
}
}

Step 8:

Cleanup

In this API drinkType and type appear to refer to the same thing. type comes from a query parameter, whereas drinkType comes from the response body.

This kind of pattern can be found in more legacy APIs, where parameters have been moved around and renamed, but older versions of those attributes are left around for backwards capability reasons.

To clean up, we have many options we can apply to the API to describe what we want to happen:

  1. x-speakeasy-ignore: true could be applied to the query parameter. After this point, it won't be configurable, and will never be sent.
  2. x-speakeasy-match: drinkType could be applied to the query parameter. This will cause it to always be sent in the request, the same as the drink_type property.
  3. x-speakeasy-name-override: type could be applied to the drinkType property. This will rename it as type, and ensure both drinkType request body key and type query parameter are both always sent.
openapi.yaml
derived.yaml
drink_resource.go

func (r *DrinkResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Drink Resource",
Attributes: map[string]schema.Attribute{
"drink_type": schema.StringAttribute{
Optional: true,
Computed: true
Description: `The type of drink.`,
stringvalidator.OneOf(
"cocktail",
"non-alcoholic",
"beer",
"wine",
"spirit",
"other",
),
},
"name": schema.Int64Attribute{
Required: true,
Description: `The name of the drink.`,
},
"price": schema.StringAttribute{
Required: true,
Description: `The price of one unit of the drink in US cents.`,
},
},
}
}

Step 1:

Adding a x-speakeasy-entity-operation: Drink#create annotation marks the POST /drinks operation as CREATING a drink resource.

Step 2:

Parameters, Request Bodies, and Response Bodies (associated with a 2XX status code) are each considered roots of the Terraform Type Schema

Step 3

The Terraform Type Schema merges all 3 of these together, and inferring links between the Operation and the Attributes. Note that similarly named attributes are merged together, and that the DrinkType attribute is inferred to be a DrinkType enum, rather than a string.

Step 4

These attributes are then merged together. If any properties conflict in type, an error is raised.

Step 5

First, the type is taken from the OpenAPI type. It is Optional: true because it is not required: true or nullable: true. The description is taken from the OpenAPI description. The examples will be used to generate an example for the documentation

Step 6

Second, the drinkType is taken from the request and response bodies. It's converted to snake case as drink_type to follow terraform best-practices.

It is Optional: true because it was not a member of the OpenAPI requiredProperties. It is Computed: true because even if not defined, the API is defined to (optionally) return a value for it.

A plan validator is added with each of the enum values. This will be used to validate the plan at plan-time.

Step 7

The other parameters are also pulled in from the request body. Both of them are required, with their type being derived from the equivalent Terraform primitive to their OpenAPI type.

Step 8:

Cleanup

In this API drinkType and type appear to refer to the same thing. type comes from a query parameter, whereas drinkType comes from the response body.

This kind of pattern can be found in more legacy APIs, where parameters have been moved around and renamed, but older versions of those attributes are left around for backwards capability reasons.

To clean up, we have many options we can apply to the API to describe what we want to happen:

  1. x-speakeasy-ignore: true could be applied to the query parameter. After this point, it won't be configurable, and will never be sent.
  2. x-speakeasy-match: drinkType could be applied to the query parameter. This will cause it to always be sent in the request, the same as the drink_type property.
  3. x-speakeasy-name-override: type could be applied to the drinkType property. This will rename it as type, and ensure both drinkType request body key and type query parameter are both always sent.
openapi.yaml

/drinks:
post:
x-speakeasy-entity-operation: Drink#create
parameters:
- name: type
in: query
description: The type of drink
required: false
deprecated: true
schema:
$ref: "#/components/schemas/DrinkType"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Drink"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Drink"
"5XX":
$ref: "#/components/responses/APIError"
default:
$ref: "#/components/responses/UnknownError"
components:
schemas:
Drink:
type: object
properties:
name:
description: The name of the drink.
type: string
examples:
- Old Fashioned
- Manhattan
- Negroni
type:
$ref: "#/components/schemas/DrinkType"
price:
description: The price of one unit of the drink in US cents.
type: number
examples:
- 1000 # $10.00
- 1200 # $12.00
- 1500 # $15.00
required:
- name
- price
DrinkType:
description: The type of drink.
type: string
enum:
- cocktail
- non-alcoholic
- beer
- wine
- spirit
- other