Rule Cascade
LearnOperators

String operators

len, lower, upper, trim, concat, substring, text, startsWith, endsWith and contains work with text.

String operators measure, change and search text. They count Unicode code points, so len of "café" is 4 in every language. lower and upper change only the letters A to Z.

Syntax

string operators
{ op: len, args: [<string or list>] }
{ op: substring, args: [<string>, <start>, <length>] }
{ op: concat, args: [<string>, <string>, ...] }
OperatorArgumentsResult
lenstring or listNumber of code points, or of elements
lower, upperstringOnly the ASCII letters A-Z / a-z change
trimstringWithout leading and trailing space, tab, line feed and carriage return
concatany number of stringsThe joined string; "" for no arguments
substringstring, start, optional lengthBy code points, from 0. Ranges beyond the end are cut
textanyThe value as text: 1500 becomes "1500"
startsWith, endsWith, containsstring, stringBoolean

Example

Ten rules check one customer. The request has an upper-case user name, a space before the name and a French VAT number for a Spanish customer.

string.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.string, version: 1.0.0, title: String operators }
scope:
  - { level: organization, id: learn }
entities:
  Customer:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Customer" }
rules:
  - id: customer.username.length
    kind: validation
    target: { entity: Customer, field: /username }
    operations: [create]
    assert: { op: between, args: [{ op: len, args: [{ var: data.username }] }, 3, 20] }
    severity: error
    finding: { code: STR-001, message: username.length }
  - id: customer.username.lower-case
    kind: validation
    target: { entity: Customer, field: /username }
    operations: [create]
    assert: { op: eq, args: [{ var: data.username }, { op: lower, args: [{ var: data.username }] }] }
    severity: error
    finding: { code: STR-002, message: username.lowerCase }
  - id: customer.country.upper-case
    kind: validation
    target: { entity: Customer, field: /country }
    operations: [create]
    assert: { op: eq, args: [{ var: data.country }, { op: upper, args: [{ var: data.country }] }] }
    severity: error
    finding: { code: STR-003, message: country.upperCase }
  - id: customer.name.trimmed
    kind: validation
    target: { entity: Customer, field: /name }
    operations: [create]
    assert: { op: eq, args: [{ var: data.name }, { op: trim, args: [{ var: data.name }] }] }
    severity: error
    finding: { code: STR-004, message: name.trimmed }
  - id: customer.email.at-sign
    kind: validation
    target: { entity: Customer, field: /email }
    operations: [create]
    assert: { op: contains, args: [{ var: data.email }, "@"] }
    severity: error
    finding: { code: STR-005, message: email.atSign }
  - id: customer.email.not-invalid
    kind: validation
    target: { entity: Customer, field: /email }
    operations: [create]
    assert: { op: not, args: [{ op: endsWith, args: [{ var: data.email }, ".invalid"] }] }
    severity: error
    finding: { code: STR-006, message: email.invalidDomain }
  - id: customer.website.https
    kind: validation
    target: { entity: Customer, field: /website }
    operations: [create]
    assert: { op: startsWith, args: [{ var: data.website }, "https://"] }
    severity: error
    finding: { code: STR-007, message: website.https }
  - id: customer.vat-id.country
    kind: validation
    target: { entity: Customer, field: /vatId }
    operations: [create]
    assert: { op: eq, args: [{ op: substring, args: [{ var: data.vatId }, 0, 2] }, { var: data.country }] }
    severity: error
    finding: { code: STR-008, message: vatId.country }
  - id: customer.nickname.label
    kind: validation
    target: { entity: Customer, field: /nickname }
    operations: [create]
    assert: { op: eq, args: [{ var: data.nickname }, { op: concat, args: [{ var: data.name }, " (", { var: data.country }, ")"] }] }
    severity: error
    finding: { code: STR-009, message: nickname.label }
  - id: customer.credit-limit.whole
    kind: validation
    target: { entity: Customer, field: /creditLimit }
    operations: [create]
    assert: { op: not, args: [{ op: contains, args: [{ op: text, args: [{ var: data.creditLimit }] }, "."] }] }
    severity: error
    finding: { code: STR-010, message: creditLimit.whole }
messages:
  en:
    username.length: "A user name has 3 to 20 characters."
    username.lowerCase: "Write the user name in lower case."
    country.upperCase: "Write the country code in upper case."
    name.trimmed: "Remove the spaces around the name."
    email.atSign: "An e-mail address contains @."
    email.invalidDomain: "This e-mail domain does not exist."
    website.https: "The website must start with https://."
    vatId.country: "The VAT number must start with the country code."
    nickname.label: "The nickname must be the name followed by the country in brackets."
    creditLimit.whole: "The credit limit is a whole number."
tests:
  - name: an upper-case user name, a padded name and a foreign VAT number
    entity: Customer
    operation: create
    given:
      data:
        username: "AnaB"
        country: "ES"
        name: " Ana"
        email: "ana@example.com"
        website: "https://ana.example"
        vatId: "FR123456789"
        nickname: " Ana (ES)"
        creditLimit: 1500
    expect:
      decision: deny
      findings:
        - { rule: customer.username.lower-case }
        - { rule: customer.name.trimmed }
        - { rule: customer.vat-id.country }
  - name: a clean customer
    entity: Customer
    operation: create
    given:
      data:
        username: "anab"
        country: "ES"
        name: "Ana"
        email: "ana@example.com"
        website: "https://ana.example"
        vatId: "ES123456789"
        nickname: "Ana (ES)"
        creditLimit: 1500
    expect: { decision: allow, findings: [] }
  - name: a credit limit with a fraction
    entity: Customer
    operation: create
    given:
      data:
        username: "anab"
        country: "ES"
        name: "Ana"
        email: "ana@example.com"
        website: "https://ana.example"
        vatId: "ES123456789"
        nickname: "Ana (ES)"
        creditLimit: 1500.5
    expect:
      decision: deny
      findings:
        - { rule: customer.credit-limit.whole }
request.json
{
  "entity": "Customer",
  "operation": "create",
  "data": {
    "username": "AnaB",
    "country": "ES",
    "name": " Ana",
    "email": "ana@example.com",
    "website": "https://ana.example",
    "vatId": "FR123456789",
    "nickname": " Ana (ES)",
    "creditLimit": 1500
  }
}

Result, from the engine

Decisiondeny3 findings, server channel

  • STR-002errorblockingWrite the user name in lower case./username
  • STR-004errorblockingRemove the spaces around the name./name
  • STR-008errorblockingThe VAT number must start with the country code./vatId
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Common mistakes

  • concat joins strings only. Turn a number into text first with text.
  • String operators on a missing field are an evaluation error. Guard optional fields with when.
  • lower and upper do not change accented letters: upper of "é" is "é".

Exercise

A phone number must start with + and have at most 16 characters. Write two rules. Test "0034612345678" (denied), "+34 612 345 678 90" (denied) and "+34612345678" (allowed).

Hint

Use startsWith with "+" for the first rule, and len with lte for the second.

Show answer
string.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.string, version: 1.0.0, title: String operators }
scope:
  - { level: organization, id: learn }
entities:
  Customer:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Customer" }
rules:
  - id: customer.phone.plus
    kind: validation
    target: { entity: Customer, field: /phone }
    operations: [create]
    assert: { op: startsWith, args: [{ var: data.phone }, "+"] }
    severity: error
    finding: { code: STR-001, message: phone.plus }
  - id: customer.phone.length
    kind: validation
    target: { entity: Customer, field: /phone }
    operations: [create]
    assert: { op: lte, args: [{ op: len, args: [{ var: data.phone }] }, 16] }
    severity: error
    finding: { code: STR-002, message: phone.length }
messages:
  en:
    phone.plus: "Start the phone number with + and the country code."
    phone.length: "A phone number has at most 16 characters."
tests:
  - name: a phone number without the plus is denied
    entity: Customer
    operation: create
    given:
      data: { phone: "0034612345678" }
    expect:
      decision: deny
      findings:
        - { rule: customer.phone.plus, fields: [/phone] }
  - name: a phone number that is too long is denied
    entity: Customer
    operation: create
    given:
      data: { phone: "+34 612 345 678 90" }
    expect:
      decision: deny
      findings:
        - { rule: customer.phone.length }
  - name: an international phone number is allowed
    entity: Customer
    operation: create
    given:
      data: { phone: "+34612345678" }
    expect: { decision: allow, findings: [] }
request.json
{
  "entity": "Customer",
  "operation": "create",
  "data": {
    "phone": "0034612345678"
  }
}

Result, from the engine

Decisiondeny1 finding, server channel

  • STR-001errorblockingStart the phone number with + and the country code./phone
Course overview

On this page