Skip to content

Latest commit

 

History

History
479 lines (392 loc) · 13.3 KB

File metadata and controls

479 lines (392 loc) · 13.3 KB

Jsonnet Examples for Sroto

Complete Jsonnet examples and patterns for using sroto.

Table of Contents

Importing Example

Imports can either be from .jsonnet files or specified raw (from .proto files):

// filename: import_example.jsonnet

local sroto = import "sroto.libsonnet";
// importing from another sroto file
local example = import "example.jsonnet";

// "importing" from a protobuf file
local Timestamp = {
    name: "Timestamp",
    filename: "google/protobuf/timestamp.proto",
    package: "google.protobuf",
};

sroto.File("import_example.proto", "import_example", {
    LogEntry: sroto.Message({
        message: sroto.StringField(1),
        priority: sroto.Field(example.Priority, 2),
        created_at: sroto.Field(Timestamp, 3),
        // Well-known types (like Timestamp) are pre-defined in sroto.WKT,
        // so the above could be simplified by doing:
        updated_at: sroto.Field(sroto.WKT.Timestamp, 4),
    }),
})

Generated output:

// filename: import_example.proto

// Generated by srotoc. DO NOT EDIT!

syntax = "proto3";

package import_example;

import "example.proto";
import "google/protobuf/timestamp.proto";

message LogEntry {
    string message = 1;
    example.Priority priority = 2;
    google.protobuf.Timestamp created_at = 3;
    google.protobuf.Timestamp updated_at = 4;
}

This bundles type information together:

  1. Type name (the enum, message, or custom option)
  2. Which file the type is declared in
  3. Package of that file

Options Example

Suppose we want a UUID field with OpenAPI documentation via grpc-gateway and validation via protoc-gen-validate.

In raw protobuf this is not composable:

import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";

message User {
    string id = 1 [
        (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
            pattern: "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}",
            min_length: 36  // quick quiz: are trailing commas permitted? (answer: no)
        },
        (validate.rules).string.uuid = true
    ];
    ...
}

With sroto, create a reusable UUIDField:

// filename: my_custom_fields.libsonnet

local sroto = import "sroto.libsonnet";

{
    UUIDField(number):: sroto.StringField(number) {
        // note: `options+:` not `options:` - we don't want to overwrite existing options
        options+: [
            {
                type: {
                    name: "openapiv2_field",
                    filename: "protoc-gen-openapiv2/options/annotations.proto",
                    package: "grpc.gateway.protoc_gen_openapiv2.options",
                },
                value: {
                    pattern: "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}",
                    min_length: 36,
                },
            },
            {
                // equivalent to (validate.rules).string.uuid = true
                type: {
                    name: "rules",
                    filename: "validate/validate.proto",
                    package: "validate",
                },
                path: "string.uuid",
                value: true,
            },
        ],
    },
}

Then use it anywhere:

// filename: options_example.jsonnet

local sroto = import "sroto.libsonnet";
local my_custom_fields = import "my_custom_fields.libsonnet";

// "import" the custom options
local openapiv2Annotation(name_suffix) = {
    name: "openapiv2_%s" % [name_suffix],
    filename: "protoc-gen-openapiv2/options/annotations.proto",
    package: "grpc.gateway.protoc_gen_openapiv2.options",
};
local api_field_behavior = {
    name: "field_behavior",
    filename: "google/api/field_behavior.proto",
    package: "google.api",
};
local http_api = {
    name: "http",
    filename: "google/api/annotations.proto",
    package: "google.api",
};

sroto.File("options_example.proto", "example", {
    User: sroto.Message({
        id: my_custom_fields.UUIDField(1),
        referrer_user_id: my_custom_fields.UUIDField(2),
    }) {
        options: [{
            type: openapiv2Annotation("schema"),
            path: "example",
            value: std.manifestJsonEx({
                id: "24508faf-20e3-46ca-8b09-d079b595ef0b",
                referrer_user_id: "27fa4a4e-5650-484f-87f9-bd915889f92b",
            }, "  "),
        }],
    },
    GetUserRequest: sroto.Message({
        id: sroto.StringField(1) {
            options: [{
                type: api_field_behavior,
                value: [sroto.EnumValueLiteral("REQUIRED")],
            }],
        },
    }),
    UserService: sroto.Service({
        GetUser: sroto.UnaryMethod("GetUserRequest", "User") {
            options: [{
                type: http_api,
                value: {get: "/users/{id}"},
            }],
        },
    }) {
        options: [{
            type: openapiv2Annotation("tag"),
            path: "description",
            value: "UserService is for various operations on users.",
        }],
    },
}) {
    // Built-in options use simple key/value syntax
    options: [
        {go_package: "github.com/tomlinford/sroto/example/options_example"}
    ],
}

Composed Fields Example

Create reusable custom field types by composing fields with options at multiple levels:

// filename: composed_fields_example.jsonnet

// Example demonstrating field composition with options at multiple levels
local sroto = import "sroto.libsonnet";

// Layer 1: Base custom field with validation options
local StringFieldWithValidation(number, min_len, max_len) =
  sroto.StringField(number) {
    options+: [{
      type: {
        name: 'rules',
        filename: 'validate/validate.proto',
        package: 'validate',
      },
      path: 'string',
      value: {
        min_len: min_len,
        max_len: max_len,
      },
    }],
  };

// Layer 2: UUID field built on validated string field
local UUIDField(number) =
  StringFieldWithValidation(number, 36, 36) {
    options+: [{
      type: {
        name: 'rules',
        filename: 'validate/validate.proto',
        package: 'validate',
      },
      path: 'string.uuid',
      value: true,
    }],
  };

// Layer 3: Required UUID field built on UUID field
local RequiredUUIDField(number) =
  UUIDField(number) {
    options+: [{
      type: {
        name: 'field_behavior',
        filename: 'google/api/field_behavior.proto',
        package: 'google.api',
      },
      value: [sroto.EnumValueLiteral('REQUIRED')],
    }],
  };

sroto.File('composed_fields_example.proto', 'composed_fields_example', {
  User: sroto.Message({
    // Uses base custom field
    nickname: StringFieldWithValidation(1, 3, 50),
    // Uses layer 2 (composed) custom field
    user_id: UUIDField(2),
    // Uses layer 3 (double composed) custom field
    tenant_id: RequiredUUIDField(3),
  }),
})

Generated output:

// filename: composed_fields_example.proto

// Generated by srotoc. DO NOT EDIT!

syntax = "proto3";

package composed_fields_example;

import "google/api/field_behavior.proto";
import "validate/validate.proto";

message User {
    string nickname = 1 [
        (validate.rules) = {string: {max_len: 50, min_len: 3}}
    ];
    string user_id = 2 [(validate.rules).string.uuid = true];
    string tenant_id = 3 [
        (google.api.field_behavior) = REQUIRED,
        (validate.rules).string.uuid = true
    ];
}

Note how tenant_id combines options from multiple composition layers - both the REQUIRED field behavior from RequiredUUIDField and the UUID validation from UUIDField. The options+: syntax ensures that options are accumulated rather than overwritten at each composition layer.

Options Merging Behavior

When composing fields with options, it's important to understand how options are merged:

  • Using options+: - Appends to the existing options array (recommended for composition)
  • Using options: - Replaces the entire options array (rarely what you want)

Options are accumulated in an array, and the protobuf compiler processes them in order. If multiple options set the same field:

  • Different types: All options are applied independently
  • Same type, different paths: Both options are applied (e.g., (validate.rules).string.min_len and (validate.rules).string.uuid)
  • Same type, same path: Later options in the array take precedence

Example of option conflict:

// This field has conflicting min_len values
local ConflictingField = sroto.StringField(1) {
  options+: [
    { type: rules, path: "string.min_len", value: 5 },
    { type: rules, path: "string.min_len", value: 10 },  // This wins
  ],
};

In practice, avoid conflicts by designing your composed fields carefully. Each composition layer should add complementary options, not conflicting ones.

Custom Options Example

Define custom options in your schemas:

// filename: custom_options_example.jsonnet

local sroto = import "sroto.libsonnet";

sroto.File("custom_options_example.proto", "custom_options_example", {
    SQLTableOptions: sroto.Message({
        table_name: sroto.StringField(1),
        table_tags: sroto.Field(sroto.WKT.Struct, 2),
        table_bin_data: sroto.BytesField(3),
        // Obviously using StringValues doesn't really make sense for custom
        // options, but the example is here for illustrative purposes.
        prev_table_name: sroto.Field(sroto.WKT.StringValue, 4),
        next_table_name: sroto.Field(sroto.WKT.StringValue, 5),
    }),
    sql_table: sroto.CustomMessageOption("SQLTableOptions", 6072),
    SQLType: sroto.Enum({
        BIGINT: 1,
        TEXT: 2,
    }),
    sql_type: sroto.CustomFieldOption("SQLType", 6073),
})

Use the custom options:

// filename: using_custom_options_example.jsonnet

local sroto = import "sroto.libsonnet";
local custom_options_example = import "custom_options_example.jsonnet";

sroto.File("using_custom_options_example.proto", "using_custom_options_example", {
    UserTable: sroto.Message({
        id: sroto.StringField(1) {options+: [{
            // note how we can just use the `sroto` objects directly here:
            type: custom_options_example.sql_type,
            value: custom_options_example.SQLType.TEXT,
        }]},
    }) {options+: [{
        type: custom_options_example.sql_table,
        value: {
            table_name: "users",
            // Can encode an arbitrary object!
            table_tags: sroto.WKT.StructLiteral(
                {foo: "bar", baz: ["qux", "quz"], teapot: null},
            ),
            table_bin_data: sroto.BytesLiteral([0, 1, 2, 3, 4, 5, 6, 7, 8]),
            prev_table_name: sroto.WKT.StringValueLiteral("old_users"),
            next_table_name: null, // This entry will get omitted.
        },
    }]},
})

Generated output:

// filename: using_custom_options_example.proto

// Generated by srotoc. DO NOT EDIT!

syntax = "proto3";

package using_custom_options_example;

import "custom_options_example.proto";

message UserTable {
    option (custom_options_example.sql_table) = {
        prev_table_name: {value: "old_users"},
        table_bin_data: "\x00\x01\x02\x03\x04\x05\x06\a\b",
        table_name: "users",
        table_tags: {
            fields: [
                {
                    key: "baz",
                    value: {
                        list_value: {
                            values: [
                                {string_value: "qux"},
                                {string_value: "quz"}
                            ]
                        }
                    }
                },
                {key: "foo", value: {string_value: "bar"}},
                {key: "teapot", value: {null_value: NULL_VALUE}}
            ]
        }
    };

    string id = 1 [(custom_options_example.sql_type) = TEXT];
}

Importing from .proto Files

Import generated files into hand-written .proto files:

// filename: protobuf_example.proto

syntax = "proto3";

package protobuf_example;

import "example.proto";

message Bug {
    string description = 1;
    example.Priority priority = 2;
}

This lets you adopt sroto gradually while downstream users continue using raw .proto files.

Generating Multiple .proto Files

Return an array to generate multiple files from one .jsonnet file:

// filename: multiple_file_example.jsonnet

local sroto = import "sroto.libsonnet";

[
    sroto.File("example_%s.proto" % [x], "example_%s" % [x], {
        [std.asciiUpper(x)]: sroto.Message({
            message: sroto.StringField(1),
        })
    }) for x in ["a", "b"]
]

Generated files:

// filename: example_a.proto
syntax = "proto3";
package example_a;

message A {
    string message = 1;
}
// filename: example_b.proto
syntax = "proto3";
package example_b;

message B {
    string message = 1;
}

See Also