02 - Resource Schema
This section defines the File resource type in PKL. The schema describes the properties of files we'll manage on SFTP servers.
Most plugins define multiple resource types - for example, an AWS plugin might have EC2::Instance, S3::Bucket, RDS::Database, and dozens more. Our SFTP plugin only needs one resource type (File), which keeps the tutorial focused. If you're building a plugin with multiple resource types, see 15 - Real-World Plugins for patterns on organizing schemas and dispatching operations by resource type.
Design the Resource
Before writing the schema, think about the properties of a file on an SFTP server:
| Property | Type | Mutability | Description |
|---|---|---|---|
path |
String | Immutable | File path on the server. Changing it means a different file. |
content |
String | Mutable | Text content of the file |
permissions |
String | Immutable | Unix permissions (e.g., "0644"). Changing requires replace. |
The path is the identifier - it uniquely identifies the file on the server.
Note that we don't include computed values like file size or modification time in the schema. The schema defines what users declare - computed values are returned by your plugin's Read operation but aren't part of the declarative specification.
Write the Schema
Replace the contents of schema/pkl/sftp.pkl:
module sftp
import "@formae/formae.pkl"
/// A text file on an SFTP server.
@formae.ResourceHint {
type = "SFTP::Files::File"
identifier = "$.path"
}
class File extends formae.Resource {
fixed hidden type: String = "SFTP::Files::File"
/// Path to the file on the SFTP server.
/// Cannot be changed after creation - changing the path means a different file.
@formae.FieldHint { createOnly = true }
path: String
/// Text content of the file.
@formae.FieldHint {}
content: String
/// Unix file permissions (e.g., "0644", "0755").
/// Defaults to "0644" if not specified.
@formae.FieldHint { createOnly = true }
permissions: String = "0644"
}
Schema Annotations
PKL annotations provide metadata that tells formae how to handle your resources and fields. For complete documentation of all annotation options, see the Schema Reference.
@formae.ResourceHint
Every resource class needs a @formae.ResourceHint annotation with two required fields:
@formae.ResourceHint {
type = "SFTP::Files::File"
identifier = "$.path"
}
- type - The full resource type following the pattern
NAMESPACE::SERVICE::RESOURCE - identifier - A JSONPath expression that extracts the unique ID from the resource's JSON representation
The identifier is critical - when your plugin returns resource properties, formae uses this path to extract the native ID. Here we use $.path because the file path uniquely identifies the file on the server.
@formae.FieldHint
Every property needs a @formae.FieldHint annotation for formae to track it. Without this annotation, formae won't detect changes to the field during updates.
// Mutable property - can be changed after creation
@formae.FieldHint {}
content: String
// Immutable property - changing it forces resource replacement
@formae.FieldHint { createOnly = true }
path: String
| Option | Meaning |
|---|---|
{} (empty) |
Normal mutable property - can be updated in place |
createOnly = true |
Immutable - changing it forces a replace (delete + create) |
In our schema:
- content has @formae.FieldHint {} - it can be updated
- path and permissions have createOnly = true - changing either requires replacing the file
Type Field
Every resource needs a type field. Use fixed hidden to make it a constant that doesn't appear in user-facing output:
fixed hidden type: String = "SFTP::Files::File"
fixed- Value cannot be changedhidden- Not shown in output (formae adds it automatically)
Validate the Schema
Use the PKL CLI to check for syntax errors:
cd formae-plugin-sftp
formae eval schema/pkl/sftp.pkl
If valid, you'll see the evaluated output. If there are errors, PKL will show the line number and issue.
Verify Schema Conventions
Beyond PKL syntax, formae provides a verify-schema tool that checks your schema follows formae conventions:
make verify-schema
This validates:
- Resource type naming - Types must start with your plugin's namespace (e.g.,
SFTP::) - No duplicate types - Each resource type must be defined exactly once
- No duplicate files - Schema files must have unique names across subdirectories
- Required annotations - Resources have
@formae.ResourceHintwithtypeandidentifier
Run this whenever you add or modify schema files. It catches mistakes early that would otherwise cause confusing errors at runtime - like a typo in your namespace or accidentally defining the same type in two files.
For plugins with many resource types (like AWS with 200+ types), this check becomes essential for maintaining schema integrity.
Rebuild the Plugin
After changing the schema, rebuild:
make build
The SDK reads schemas at startup, so the binary itself doesn't change - but rebuilding verifies everything still compiles.
Understanding the Identifier
The identifier = "$.path" uses JSONPath to tell formae how to find the native ID in your resource's JSON. When your Create method returns:
{
"path": "/data/config.txt",
"content": "debug=true",
"permissions": "0644"
}
formae extracts /data/config.txt as the native ID using the JSONPath $.path.
This native ID is then passed to Read, Update, and Delete requests so your plugin knows which file to operate on.