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:
- Create the drink: Invoke
POST /drink
to create the drink entity. - 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: truecontent:application/json:schema:type: objectproperties:name:type: stringrequired: [name]responses:"200":content:application/json:schema:type: objectproperties:data:type: objectproperties:id:type: stringrequired: [id]required: [data]/drink/{id}/visibility:post:requestBody:required: truecontent:application/json:schema:type: objectproperties:visibility:type: stringenum:- public- privateresponses:"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#createrequestBody:required: truecontent:application/json:schema:type: objectproperties:name:type: stringrequired: [name]responses:"200":content:application/json:schema:type: objectproperties:data:type: objectproperties:id:type: stringrequired: [id]required: [data]/drink/{id}/visibility:post:x-speakeasy-entity-operation: Drink#create#2requestBody:required: truecontent:application/json:schema:type: objectproperties:visibility:type: stringenum:- public- privateresponses:"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#createrequestBody:required: truecontent:application/json:schema:x-speakeasy-entity: Drinktype: objectproperties:name:type: stringrequired: [name]responses:"200":content:application/json:schema:type: objectproperties:data:type: objectx-speakeasy-entity: Drinkproperties:id:type: stringrequired: [id]required: [data]/drink/{id}/visibility:post:x-speakeasy-entity-operation: Drink#create#2requestBody:required: truecontent:application/json:schema:type: objectx-speakeasy-entity: Drinkproperties:visibility:type: stringenum:- public- privateresponses:"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.
/drinks:post:x-speakeasy-entity-operation: Drink#createparameters:- name: typein: querydescription: The type of drinkrequired: falsedeprecated: trueschema:$ref: "#/components/schemas/DrinkType"requestBody:required: truecontent: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: objectproperties:name:description: The name of the drink.type: stringexamples:- Old Fashioned- Manhattan- Negronitype:$ref: "#/components/schemas/DrinkType"price:description: The price of one unit of the drink in US cents.type: numberexamples:- 1000 # $10.00- 1200 # $12.00- 1500 # $15.00required:- name- priceDrinkType:description: The type of drink.type: stringenum:- 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
/drinks:post:x-speakeasy-entity-operation: Drink#createparameters:- name: typein: querydescription: The type of drinkrequired: falsedeprecated: trueschema:$ref: "#/components/schemas/DrinkType"requestBody:required: truecontent: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: objectproperties:name:description: The name of the drink.type: stringexamples:- Old Fashioned- Manhattan- Negronitype:$ref: "#/components/schemas/DrinkType"price:description: The price of one unit of the drink in US cents.type: numberexamples:- 1000 # $10.00- 1200 # $12.00- 1500 # $15.00required:- name- priceDrinkType:description: The type of drink.type: stringenum:- 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
.
- create.parameters:type:from: "paths["/drinks"].post.parameters[0]"type: enumenumValues:type: stringvalues:- cocktail- non-alcoholic- beer- wine- spirit- other- create.requestBody:name:from: "components.schemas.Drink.properties.name"description: The name of the drink.type: stringexamples:- Old Fashioned- Manhattan- NegronidrinkType:from: "components.schemas.Drink.properties.type"type: stringenum:- cocktail- non-alcoholic- beer- wine- spirit- otherprice:from: "components.schemas.Drink.properties.price]"type: number- create.successResponseBody:name:from: "components.schemas.Drink.properties.name"description: The name of the drink.type: stringexamples:- Old Fashioned- Manhattan- NegronidrinkType:from: "components.schemas.Drink.properties.type"type: stringenum:- cocktail- non-alcoholic- beer- wine- spirit- otherprice: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.
- create.requestShard:type:from: "paths["/drinks"].post.parameters[0]"type: enumenumValues:type: stringvalues:- cocktail- non-alcoholic- beer- wine- spirit- othername:from: "components.schemas.Drink.properties.name"description: The name of the drink.type: stringexamples:- Old Fashioned- Manhattan- NegronidrinkType:from: "components.schemas.Drink.properties.type"type: stringenum:- cocktail- non-alcoholic- beer- wine- spirit- otherprice:from: "components.schemas.Drink.properties.price]"type: number- create.responseShard:name:from: "components.schemas.Drink.properties.name"description: The name of the drink.type: stringexamples:- Old Fashioned- Manhattan- NegronidrinkType:from: "components.schemas.Drink.properties.type"type: stringenum:- cocktail- non-alcoholic- beer- wine- spirit- otherprice: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
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.
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: trueDescription: `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.
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: trueDescription: `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:
x-speakeasy-ignore: true
could be applied to the query parameter. After this point, it won’t be configurable, and will never be sent.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 thedrink_type
property.x-speakeasy-name-override: type
could be applied to thedrinkType
property. This will rename it astype
, and ensure bothdrinkType
request body key andtype
query parameter are both always sent.
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: trueDescription: `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.`,},},}}