Skip to main content

Create a RESTful custom resource

The REST resource DSL is a base resource class in Chef Infra Client that allows you to create custom resources that interact with RESTful APIs. Instead of writing HTTP request handling code from scratch, you can extend this resource to create custom resources that automatically handle API interactions, JSON mapping, and state management.

With the REST resource, you can:

  • Define resource properties that map directly to API fields.
  • Declare the target API endpoint directly in the resource class.
  • Use built-in actions to create, update, and delete resources with REST APIs.
  • Create nested JSON structures using JMESPath expressions.
  • Handle authentication, pagination, and error conditions cleanly.

Requirements

The REST custom resource DSL has the following requirements:

  • The custom resource must use the core::rest_resource partial.
  • Use property to define the properties you want to map to the REST API.
  • The target REST API endpoints (collection and document URLs) must be accessible.
  • The Chef Infra Client node must have network access to the REST API endpoints.
  • Any required API authentication (tokens, credentials) must be handled, typically with resource properties or configuration.

Configure the API endpoint

You can configure an API endpoint using either the rest_api_endpoint method or by using Train transport.

Note

The rest_api_endpoint method is new in Chef Infra Client 19.3.

Set the base URL directly on the resource class using rest_api_endpoint. The resource is then fully self-describing—everything a developer needs to understand what API it talks to lives in one place.

class Chef::Resource::ApiUser < Chef::Resource
  use "core::rest_resource"

  resource_name :api_user
  provides :api_user

  rest_api_endpoint   "https://api.example.com"
  rest_api_collection "/api/v1/users"
  rest_identity_property :username

  property :username, String, name_property: true
  property :email,    String

  rest_property_map({ username: "username", email: "email" })
end

The resource constructs full URLs automatically:

GET  https://api.example.com/api/v1/users
GET  https://api.example.com/api/v1/users/jdoe
POST https://api.example.com/api/v1/users

Train transport endpoint

Configure the base URL in the Chef target mode transport (Train). The resource then uses relative paths only, and Train prepends its configured endpoint.

  1. Set the transport endpoint using either a Policyfile or a knife configuration file:

    # .chef/config.rb or Policyfile transport block
    transport:
      name:     rest
      endpoint: https://api.example.com
    
  2. With the endpoint’s base URL configured, define the resource’s relative paths:

    class Chef::Resource::ApiUser < Chef::Resource
      use "core::rest_resource"
    
      resource_name :api_user
      provides :api_user
    
      # No rest_api_endpoint---base URL comes from the Train transport
      rest_api_collection "/api/v1/users"
      rest_api_document   "/api/v1/users/{username}"
    
      property :username, String, name_property: true
      property :email,    String
    
      rest_property_map({ username: "username", email: "email" })
    end
    

Why rest_api_endpoint is the better choice

When using the Train transport approach, the endpoint is invisible from the resource definition. A developer reading the resource has no way to know which API it targets without searching through Chef configuration files. If a cookbook needs to talk to two different REST APIs, you would need two separate transport configurations.

rest_api_endpoint removes this ambiguity. The resource declares exactly where it connects, just like it declares its properties and mappings. Use rest_api_endpoint unless you have a specific reason not to.

rest_api_endpointTrain transport
API URL visible in resource codeYesNo—must look elsewhere
Works without target mode configYesRequires transport setup
Each resource can target a different APIYesAll resources share one transport
Resource is self-containedYesSplit across two files
Easy to test independentlyYesRequires transport mock

Basic example

This example does the following:

  • Creates the api_user resource with the "core::rest_resource" partial
  • Declares the API endpoint using rest_api_endpoint
  • Defines an API document and collection
  • Defines resource properties
  • Maps properties to JSON API fields
  • Uses the custom resource in a recipe to add and remove a user
class Chef::Resource::ApiUser < Chef::Resource
  use "core::rest_resource"

  resource_name :api_user
  provides :api_user

  # Configure the API document and collection
  rest_api_endpoint      "https://api.example.com"
  rest_api_collection    "/api/v1/users"
  rest_identity_property :username
  rest_api_document "/api/v1/users/{username}"

  # Define resource properties
  property :username, String, name_property: true, identity: true
  property :email, String, required: true
  property :active, [true, false], default: true

  # Map properties to JSON fields
  rest_property_map({
    username: "username",
    email: "email",
    active: "active"
  })
end

Once defined, you can use the custom resource to add and remove a user in a recipe. For example:

# Create or update a user
api_user "jdoe" do
  email "jdoe@example.com"
  active true
  action :configure
end

# Delete a user
api_user "jdoe" do
  action :delete
end

Methods and actions

The rest_resource has the following methods and actions.

Methods

The REST resource provides several DSL methods for configuring API interactions. These methods are called within your custom resource class definition.

rest_api_endpoint

New in Chef Infra Client 19.3.

This method sets the base URL of the REST API. When set, it’s prepended to the collection and document paths to form the complete request URL, making the resource fully self-contained and independent of the Train transport endpoint.

This method has the following syntax:

rest_api_endpoint "https://hostname"
  • The value must be a fully-qualified URL including scheme (https://)
  • Don’t include a trailing slash
  • Subclasses inherit this value unless they override it

For example:

rest_api_endpoint "https://api.example.com"

rest_identity_property

New in Chef Infra Client 19.3.

This method declares which property uniquely identifies the resource in the API. When set, rest_api_document is automatically generated as "#{rest_api_collection}/{property}", so you don’t need to set rest_api_document separately.

This method has the following syntax:

rest_identity_property :property_name
  • Accepts a single symbol matching a defined resource property
  • Subclasses inherit this value unless they override it

For example:

rest_api_collection    "/api/v1/users"
rest_identity_property :username
# Equivalent to: rest_api_document "/api/v1/users/{username}"

rest_api_collection

This method defines the base URL path for the resource collection. Chef Infra Client uses this URL to list resources and create new ones.

This method has the following syntax:

rest_api_collection "/path/to/collection"
  • Path must be absolute (start with /)
  • Used for GET (list all) and POST (create) operations
  • Subclasses inherit this value unless they override it

For example:

rest_api_collection "/api/v1/users"

rest_api_document

This method defines the URL pattern for individual resource documents and supports RFC 6570 URI templates for dynamic URLs.

Note

If you set rest_identity_property instead, rest_api_document is autogenerated and you don’t need to set it manually.

This method has the following syntax:

rest_api_document "/path/to/{resource_id}", first_element_only: false

Parameters:

  • path (String): URL pattern with optional {template} placeholders matching property names
  • first_element_only (Boolean): If true, extracts the first element from an array response. Default is false.

For example:

  • Path-based selection:

    rest_api_document "/api/v1/users/{username}"
    
  • Query-based selection:

    rest_api_document "/api/v1/users?name={username}&org={organization}"
    
  • Get the first item in an array:

    rest_api_document "/api/v1/search?q={name}", first_element_only: true
    

    With first_element_only: true, if the API response returns an array, the resource extracts only the first element.

    For example, if this is the response:

    [{"name": "alice", "email": "alice@example.com"}, {"name": "bob", "email": "bob@example.com"}]
    

    The resource extracts the following: {"name": "alice", "email": "alice@example.com"}.

rest_identity_map

This method explicitly defines which properties uniquely identify a resource. This is usually inferred automatically from the document URL template variables, but can be specified for composite keys or when the JSON field name differs from the URI template variable.

This method has the following syntax:

rest_identity_map(<MAPPING>)

Replace <MAPPING> with a hash mapping JSON field paths to property symbols. For example:

# Single identity property
rest_identity_map({ "username" => :username })

# Composite identity
rest_identity_map({
  "user.name"       => :username,
  "organization.id" => :org_id
})

rest_post_only_properties

Declares properties that should only be sent during resource creation (POST) and excluded from updates (PATCH).

This method has the following syntax:

rest_post_only_properties <PROPERTY_OR_ARRAY>

Replace <PROPERTY_OR_ARRAY> with a single symbol or array of symbols representing property names. For example:

  • Single property:

    rest_post_only_properties :password
    
  • Multiple properties:

    rest_post_only_properties [:password, :initial_role, :creation_token]
    

Common use cases:

  • Passwords or secrets that can’t be updated with the API
  • Resource size or capacity that’s immutable after creation
  • Template or source identifiers used only during initialization

rest_property_map

The rest_property_map method maps resource properties to JSON API fields. This supports simple mappings and complex nested structures using JMESPath.

This method has the following syntax:

rest_property_map <MAPPING>

Replace <MAPPING> with:

  • An array of 1:1 mappings
  • A hash mapping resource properties to JSON fields or JMESPaths
  • A hash mapping resource properties to symbols for custom serialization functions

For example:

  • Array of mappings. If your property names match the JSON field names, you can use an array:

    rest_property_map [:username, :email, :role]
    # Equivalent to: { username: 'username', email: 'email', role: 'role' }
    
  • String values. If your property names differ from the JSON fields, or you need to map to nested fields, use a hash:

    rest_property_map({
      full_name: "profile.fullName",
      email:     "contact.email.primary"
    })
    
  • Symbol values. Map a property to a symbol to use custom serialization and deserialization methods:

    rest_property_map({
      tags: :tags_mapping  # Uses tags_from_json and tags_to_json methods
    })
    

See the following examples for more information:

Actions

The REST resource provides two built-in actions.

:configure (default)

The :configure action creates a new resource or updates an existing one. This action is idempotent and does the following:

  • Checks if the resource exists by querying the API
  • If it doesn’t exist: sends a POST request to create it
  • If it exists and properties are changed: sends a PATCH request to update it
  • If it exists and nothing changed: takes no action

For example:

api_user "john" do
  email "john@example.com"
  role "admin"
  action :configure  # This is the default action
end

:delete

The :delete action deletes a resource from the REST API. This action is idempotent and does the following:

  • Checks if the resource exists
  • If it exists: sends a DELETE request
  • If it doesn’t exist: takes no action

For example:

api_user "john" do
  action :delete
end

More features

Custom headers and authentication

Override the rest_headers method in your action class to add custom headers such as authentication tokens.

class Chef::Resource::ApiResource < Chef::Resource
  use "core::rest_resource"

  rest_api_endpoint   "https://api.example.com"
  rest_api_collection "/api/v1/resources"
  rest_api_document   "/api/v1/resources/{id}"

  property :id, String, name_property: true

  rest_property_map({ id: "id" })

  action_class do
    def rest_headers
      {
        "Authorization" => "Bearer #{node['api_token']}",
        "X-API-Version" => "2024-01-01",
        "Content-Type"  => "application/json"
      }
    end
  end
end

Response post-processing

Override the rest_postprocess method to transform API responses, handle pagination, or extract embedded data.

action_class do
  def rest_postprocess(response)
    # Extract data from paginated response
    if response.data.is_a?(Hash) && response.data["items"]
      response.data = response.data["items"]
    end

    # Add custom logging
    Chef::Log.debug("API response: #{response.data.inspect}")

    response
  end
end

Custom error handling

Override the rest_errorhandler method to provide user-friendly error messages or handle specific error codes.

action_class do
  def rest_errorhandler(error_obj)
    case error_obj.response&.code
    when 404
      Chef::Log.warn("Resource not found - it may have been deleted externally")
      nil
    when 429
      raise "API rate limit exceeded. Please try again later."
    when 401, 403
      raise "Authentication failed. Check your API credentials."
    else
      raise error_obj  # Re-raise for unexpected errors
    end
  end
end

Conditional property requirements

Use the conditionally_require_on_setting helper to enforce dependencies between properties.

action_class do
  def load_current_resource
    super

    # If ssl_enabled is true, require ssl_cert and ssl_key
    conditionally_require_on_setting(:ssl_enabled, [:ssl_cert, :ssl_key])
  end
end

Examples

Create a REST resource using rest_api_endpoint and rest_identity_property

The following api_user custom resource is fully self-contained. The API endpoint, collection path, and identity property are all declared in the class—no external transport configuration is needed.

class Chef::Resource::ApiUser < Chef::Resource
  use "core::rest_resource"

  resource_name :api_user
  provides :api_user

  rest_api_endpoint      "https://api.example.com"
  rest_api_collection    "/api/v1/users"
  rest_identity_property :username

  property :username,   String, name_property: true, identity: true
  property :email,      String, required: true
  property :first_name, String
  property :last_name,  String
  property :role,       String, equal_to: ["admin", "user", "readonly"], default: "user"
  property :active,     [true, false], default: true
  property :password,   String, sensitive: true

  rest_property_map({
    username:   "username",
    email:      "email",
    first_name: "profile.firstName",
    last_name:  "profile.lastName",
    role:       "permissions.role",
    active:     "status.active",
    password:   "password"
  })

  # Password can only be set during creation
  rest_post_only_properties :password
end

Use the api_user custom resource in a recipe:

api_user "alice" do
  email      "alice@example.com"
  first_name "Alice"
  last_name  "Smith"
  role       "admin"
  password   "initial-password-123"
  action     :configure
end

Create a REST resource using the Train transport endpoint

The following api_user resource omits rest_api_endpoint. The base URL is provided instead by the Chef target mode Train transport. Note that rest_api_document must now be set manually because rest_identity_property can’t generate it without a base endpoint.

class Chef::Resource::ApiUser < Chef::Resource
  use "core::rest_resource"

  resource_name :api_user
  provides :api_user

  # No rest_api_endpoint---base URL comes from the Train transport
  rest_api_collection "/api/v1/users"
  rest_api_document   "/api/v1/users/{username}"

  property :username,   String, name_property: true, identity: true
  property :email,      String, required: true
  property :first_name, String
  property :last_name,  String
  property :role,       String, equal_to: ["admin", "user", "readonly"], default: "user"
  property :active,     [true, false], default: true
  property :password,   String, sensitive: true

  rest_property_map({
    username:   "username",
    email:      "email",
    first_name: "profile.firstName",
    last_name:  "profile.lastName",
    role:       "permissions.role",
    active:     "status.active",
    password:   "password"
  })

  rest_post_only_properties :password
end

Configure the base URL in the Chef target mode transport:

# .chef/config.rb or Policyfile transport block
transport:
  name:     rest
  endpoint: https://api.example.com

The recipe usage is identical in both cases.

Use JMESPath expressions to map data in a JSON structure

JMESPath navigates and extracts data from JSON structures. The REST resource supports JMESPath for both reading from and writing to APIs.

JMESPath dot notation

You can use dot notation to specify nested data.

This code extracts data from the following JSON:

rest_property_map({
  username: "username",           # Top-level field
  email:    "contact.email",      # Nested field
  city:     "address.location.city"  # Deeply nested field
})
{
  "username": "jdoe",
  "contact": {
    "email": "jdoe@example.com",
    "phone": "+1-555-0100"
  },
  "address": {
    "location": {
      "city": "San Francisco",
      "state": "CA",
      "zip": "94102"
    }
  }
}

JMESPath wildcard notation

You can use a JMESPath wildcard expression to extract data from a JSON structure.

For example, the following extracts the email address from each member in this JSON:

rest_property_map({
  member_emails: "members[*].email"
})
{
  "members": [
    {
      "name": "Admin1",
      "email": "admin1@example.com",
      "role": "admin"
    },
    {
      "name": "User1",
      "email": "user1@example.com",
      "role": "user"
    }
  ]
}

JMESPath filter projection

You can use a filter projection to extract JSON data matching a condition.

For example, the following returns the names of active users (["Alice Johnson", "Carol White"]) from this JSON:

rest_property_map({
  active_users: "users[?active==`true`].name"  # Filter and extract
})
{
  "users": [
    {
      "id": "user-001",
      "name": "Alice Johnson",
      "email": "alice@example.com",
      "active": true,
      "department": "Engineering"
    },
    {
      "id": "user-002",
      "name": "Bob Smith",
      "email": "bob@example.com",
      "active": false,
      "department": "Sales"
    },
    {
      "id": "user-003",
      "name": "Carol White",
      "email": "carol@example.com",
      "active": true,
      "department": "Marketing"
    }
  ]
}

Create a custom mapping function with rest_property_map

For complex transformations that JMESPath can’t handle, use custom mapping functions by specifying a symbol in the property map.

To create a custom mapping function, specify a symbol (for example, :symbol_name) in rest_property_map and define two methods in the action_class:

  • A method that extracts values from an API response: property_from_json(resource_data)
  • A method that converts values for an API request: property_to_json(property_value)

In the following example, rest_property_map uses :tags_mapping to handle conversion between the resource’s hash representation and the API’s array format:

class Chef::Resource::ApiProject < Chef::Resource
  use "core::rest_resource"

  resource_name :api_project
  provides :api_project

  rest_api_endpoint      "https://api.example.com"
  rest_api_collection    "/projects"
  rest_identity_property :name

  property :name, String, name_property: true
  property :tags, Hash, default: {}

  rest_property_map({
    name: "name",
    tags: :tags_mapping  # Uses custom functions
  })

  action_class do
    # Convert API's tag array to hash
    def tags_from_json(resource_data)
      tag_array = resource_data["tags"] || []
      tag_array.to_h { |tag| [tag["key"], tag["value"]] }
    end

    # Convert hash to API's tag array format
    def tags_to_json(tags_hash)
      {
        "tags" => tags_hash.map { |k, v| { "key" => k, "value" => v } }
      }
    end
  end
end

In a recipe, you can manage tags for the mobile-app project:

# Create or update a project with specific tags
api_project "mobile-app" do
  tags({
    "environment" => "production",
    "team" => "mobile",
    "cost-center" => "engineering"
  })
  action :configure
end

# Update the project's tags
api_project "mobile-app" do
  tags({
    "environment" => "production",
    "team" => "mobile",
    "cost-center" => "engineering",
    "region" => "us-west"  # Add a new tag
  })
  action :configure
end

Cloud resource with complex mapping

This resource and recipe create and manage a virtual machine through a cloud provider’s REST API.

class Chef::Resource::CloudServer < Chef::Resource
  use "core::rest_resource"

  resource_name :cloud_server
  provides :cloud_server

  rest_api_endpoint      "https://api.cloud.example.com"
  rest_api_collection    "/api/v2/servers"
  rest_identity_property :server_id

  property :server_id, String, name_property: true, identity: true
  property :name,      String, required: true
  property :size,      String, required: true
  property :region,    String, required: true
  property :image,     String, required: true
  property :tags,      Hash, default: {}
  property :ssh_keys,  Array, default: []

  rest_property_map({
    server_id: "id",
    name:      "name",
    size:      "size.slug",
    region:    "region.slug",
    image:     "image.slug",
    tags:      :tags_mapping,
    ssh_keys:  "ssh_keys[*].id"
  })

  # Size and image can only be set at creation
  rest_post_only_properties [:size, :image]

  action_class do
    # Convert tag hash to API's array format
    def tags_from_json(resource_data)
      (resource_data["tags"] || []).to_h { |t| [t["key"], t["value"]] }
    end

    def tags_to_json(tags_hash)
      {
        "tags" => tags_hash.map { |k, v| { "key" => k, "value" => v } }
      }
    end

    def rest_headers
      {
        "Authorization" => "Bearer #{node['cloud_api_token']}",
        "Content-Type"  => "application/json"
      }
    end

    def rest_errorhandler(error_obj)
      case error_obj.response&.code
      when 402
        raise "Insufficient credits to create server"
      when 422
        raise "Invalid server configuration: #{error_obj.message}"
      else
        raise error_obj
      end
    end
  end
end

In a recipe:

cloud_server "web-01" do
  name     "web-server-01"
  size     "s-2vcpu-4gb"
  region   "nyc3"
  image    "ubuntu-22-04-x64"
  tags({ "environment" => "production", "role" => "web" })
  ssh_keys [12345, 67890]
  action   :configure
end

Query-based resource selection

This example demonstrates how to use query parameters to identify a unique resource when the API doesn’t support path-based resource selection. It also shows how to use rest_identity_map when the JSON field names differ from the URI template variable names.

class Chef::Resource::DnsRecord < Chef::Resource
  use "core::rest_resource"

  resource_name :dns_record
  provides :dns_record

  rest_api_endpoint   "https://dns.example.com"
  rest_api_collection "/api/v1/zones/example.com/records"
  rest_api_document   "/api/v1/zones/example.com/records?name={record_name}&type={record_type}"

  property :record_name, String, name_property: true
  property :record_type, String, equal_to: ["A", "AAAA", "CNAME", "MX", "TXT"], default: "A"
  property :value,       String, required: true
  property :ttl,         Integer, default: 3600

  rest_property_map({
    record_name: "name",
    record_type: "type",
    value:       "content",
    ttl:         "ttl"
  })

  # Explicitly define composite identity
  rest_identity_map({
    "name" => :record_name,
    "type" => :record_type
  })
end

In a recipe:

dns_record "www.example.com" do
  record_type "A"
  value       "192.0.2.1"
  ttl         300
  action      :configure
end

Handle paginated API responses

This example demonstrates how to handle paginated API responses in a custom REST resource.

class Chef::Resource::TeamMember < Chef::Resource
  use "core::rest_resource"

  resource_name :team_member
  provides :team_member

  rest_api_endpoint      "https://api.example.com"
  rest_api_collection    "/api/v1/teams/engineering/members"
  rest_identity_property :user_id

  property :user_id, String, name_property: true, identity: true
  property :role, String, equal_to: ["member", "maintainer", "owner"]

  rest_property_map({
    user_id: "id",
    role:    "role"
  })

  action_class do
    # Handle paginated GET responses
    def rest_postprocess(response)
      # API returns: { "data": [...], "pagination": {...} }
      if response.data.is_a?(Hash) && response.data["data"]
        response.data = response.data["data"]
      end

      response
    end

    # Fetch all pages of results
    def rest_get_all
      all_results = []
      page = 1

      loop do
        response = api_connection.get("#{rest_url_collection}?page=#{page}")
        response = rest_postprocess(response)

        break if response.data.empty?

        all_results.concat(response.data)
        page += 1

        break unless response.data.length >= 100  # Assume 100 per page
      end

      # Return mock response with all results
      response.data = all_results
      response
    end
  end
end

Troubleshooting

Debugging API requests

Enable debug logging to see API requests and responses:

action_class do
  def rest_postprocess(response)
    Chef::Log.debug("API Request: #{rest_url_document}")
    Chef::Log.debug("API Response: #{response.data.inspect}")
    response
  end
end

Common issues

Issue: “No such file” error for identity property

This usually means the identity mapping is incorrect or the document URL template doesn’t match the property name.

# Ensure template variables match property names
rest_api_document "/api/v1/users/{username}"  # Template variable: username
property :username, String, identity: true    # Property name: username

Issue: Properties not updating

Check if properties are accidentally marked as post-only:

rest_post_only_properties [:password]  # Only password is post-only
# Don't include properties that should be updatable

Issue: “Can’t resolve property to JSON”

Verify your property map includes all properties you are trying to set:

property :email, String
property :role,  String

rest_property_map({
  email: "email",
  role:  "role"
})

Issue: Request going to the wrong URL

If you are using rest_api_endpoint, confirm that the value doesn’t include a trailing slash and that rest_api_collection starts with /. The two are concatenated directly:

rest_api_endpoint   "https://api.example.com"   # no trailing slash
rest_api_collection "/api/v1/users"              # leading slash required
# Result: https://api.example.com/api/v1/users

Additional resources

Thank you for your feedback!

×