Skip to content

REST

Expose HTTP endpoints (server) or call external REST APIs (client). The server connector is the most common way to create a Mycel microservice — it receives HTTP requests and triggers flows. The client connector calls external APIs as a step or target.

Server Configuration

connector "api" {
  type = "rest"
  port = 3000

  cors {
    origins = ["*"]
    methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
    headers = ["Content-Type", "Authorization"]
  }
}
Option Type Default Description
port int — Listen port
cors.origins list — Allowed CORS origins
cors.methods list — Allowed HTTP methods
cors.headers list — Allowed headers

The server speaks plain HTTP. There is no tls block on it and no certificate to configure: terminate TLS in front of Mycel — an ingress, a load balancer, a reverse proxy — which is where certificate renewal and cipher policy already live in most deployments. The tls block below belongs to the client half, and configures how Mycel verifies the servers it calls.

Client Configuration

connector "external_api" {
  type     = "http"
  base_url = "https://api.example.com"
  timeout  = "30s"

  auth {
    type  = "bearer"    # "bearer", "api_key", "basic", "oauth2"
    token = env("API_TOKEN")
  }

  retry {
    attempts = 3
  }
}
Option Type Default Description
base_url string — Base URL for all requests
timeout duration "30s" Request timeout
auth.type string — Auth method: bearer, api_key, basic, oauth2
retry.attempts int 1 Maximum retry attempts. See HTTP Client for delay, max_delay and backoff.
retry_count int — Shorthand for retry { attempts = N }.
tls.ca_cert string — Path to a custom CA certificate (PEM) used to verify the server.
tls.cert string — Path to client certificate (PEM) for mTLS.
tls.key string — Path to client private key (PEM) for mTLS.
tls.insecure_skip_verify bool false Disable TLS certificate verification. Dev only — never use in production.

TLS

For HTTPS endpoints whose certificate is signed by a private CA (e.g. an internal corporate CA, or a mkcert-signed dev proxy) point the connector at the CA bundle:

connector "internal_api" {
  type     = "http"
  base_url = "https://internal.example.com"

  tls {
    ca_cert = "/etc/ssl/private-ca.pem"
  }
}

For mutual TLS, add the client certificate pair:

tls {
  ca_cert = "/etc/ssl/ca.pem"
  cert    = "/etc/ssl/client.pem"
  key     = "/etc/ssl/client.key"
}

For local development against a self-signed certificate (e.g. an nginx-proxy container in docker compose), skip verification entirely:

connector "magento" {
  type     = "http"
  base_url = env("MAGENTO_BASE_URL")

  tls {
    insecure_skip_verify = true   # dev only
  }
}

When insecure_skip_verify is enabled, Mycel logs a single WARN at connector startup with the connector name and base URL — loud enough that an accidental production deploy is obvious in the logs.

The block is the same on every connector that speaks TLS — see TLS for the full attribute list and for the older client_cert / client_key names, which are still accepted.

Headers per request

The connector's headers block is sent on every request. A header whose value comes from the message — the store view, the tenant, the locale — goes on the step, to or enrich that makes the call (or on a saga's or state machine's action), as CEL expressions or constants, and wins over the connector's on the same name:

step "page" {
  connector = "backend"
  operation = "POST /graphql"
  headers   = { Store = "input.store", "X-Request-Source" = "mycel" }
  body      = { query = "'{ page(id: 1) { title } }'" }
}

A header that evaluates to null is not sent at all, rather than sent empty. A field the message does not carry is an error, as in any expression; write input.store ?? 'default' for a header with a fallback. The same attribute is honoured by the graphql client and soap connectors; on any other connector mycel validate refuses it, since nothing there would read it.

Wrapping the request body — envelope

Some REST frameworks (Magento webapi, Spring @RequestBody, several SOAP-derived REST APIs) require the request body nested under a single root key matching the service method's parameter name:

{ "productData": { "style_number": "AI02LT", "name": "..." } }

Rather than wrap the body inside a CEL map literal, set envelope on the to block. The transform stays clean — one line per attribute — and Mycel wraps the entire transform output under the named key just before it reaches the connector:

flow "magento_create_style" {
  from {
    connector = "rabbit"
    target    = "all.in.magento.q"
  }

  transform {
    style_number = "input.body.payload.styleNumber"
    name         = "coalesce(input.body.payload.styleName, '')"
    websites     = "input.body.payload.websites"
    # ...30 more lines, one mapping each
  }

  to {
    connector = "magento"
    target    = "/rest/V1/mercury/products/styles"
    operation = "POST"
    envelope  = "productData"
  }
}

envelope also works on step blocks for intermediate HTTP calls that need the same shape. The wrap is a single key with the entire payload as its value — chained / nested wrappers are not supported (parenthesize manually with another transform if you need them).

Operations

Server (source): Any HTTP method + path pattern — GET /users, POST /users, PUT /users/:id, DELETE /users/:id. Path and query parameters arrive at the top of input for every method; a request body is decoded and merged there too for POST, PUT, PATCH, QUERY and DELETE (a body-less DELETE is the common case and stays as it is — before 3.6.2 a DELETE body was silently ignored). A body that does not parse is a 400.

Client (target): Same method + path syntax, resolved against base_url.

Where the data goes

POST, PUT, PATCH and QUERY carry their data in the body, and nothing else is added to the URL — a query string written into the target itself is kept, since that is what the flow asked for.

GET, DELETE and HEAD have no body, so their data is the query string. Values are rendered the way a query string can carry them: scalars as themselves, structured values as JSON, and a null omitted rather than sent. Parameters are sorted, so the same data always produces the same URL.

Fixed in 3.3.0

Before 3.3.0 a write appended its data to the URL for every method, including the ones that carry a body. For a flow writing to HTTP that data is the inbound message, so the whole message went out twice — once as the body that mattered, once as a query string nobody read. Small messages worked, so it stayed invisible until the request line passed a front-end proxy's limit and came back 414 Request-URI Too Large on an endpoint that accepted a large body without complaint. Structured values were also rendered with %v, arriving as Go's map[a:map[b:c]].

Debugging outbound requests

When the log level is set to debug (MYCEL_LOG_LEVEL=debug or --log-level=debug), the HTTP connector emits one log line per POST / PUT / PATCH request describing the body shape:

DEBUG outbound HTTP body connector=magento method=POST path=/rest/V1/products
      size_bytes=27566 top_level_keys=[productData]

Only the top-level keys and total size are logged — no values, so it is safe to enable in environments where the body may contain sensitive data. Enough to verify wrap / envelope behavior end-to-end without intercepting traffic. The log is silent at any level above debug, so production logs are not noisier when you leave the default.

Example

flow "list_users" {
  from {
    connector = "api"
    operation = "GET /users"
  }
  to {
    connector = "db"
    target    = "users"
  }
}

flow "create_user" {
  from {
    connector = "api"
    operation = "POST /users"
  }
  transform {
    id         = "uuid()"
    email      = "lower(input.email)"
    created_at = "now()"
  }
  to {
    connector = "db"
    target    = "users"
  }
}

See the basic example for a complete working setup.

File Upload (multipart/form-data)

The REST server connector auto-detects multipart/form-data requests and parses file uploads. The maximum upload size is 32MB.

Each uploaded file is encoded as a map with the following fields:

Field Type Description
filename string Original file name
content_type string MIME type (e.g., image/png)
size int File size in bytes
data string File content encoded as base64

Files are available in transforms as input.files.<field_name>, where <field_name> matches the form field name used in the multipart request. Regular (non-file) form fields are available as input.<field_name>.

Example

flow "upload_avatar" {
  from {
    connector = "api"
    operation = "POST /users/:id/avatar"
  }

  transform {
    user_id      = "input.id"
    filename     = "input.files.avatar.filename"
    content_type = "input.files.avatar.content_type"
    size         = "input.files.avatar.size"
    data         = "input.files.avatar.data"
    uploaded_at  = "now()"
  }

  to {
    connector = "db"
    target    = "user_avatars"
  }
}

Upload with curl:

curl -X POST http://localhost:3000/users/42/avatar \
  -F "avatar=@photo.jpg"

Full configuration reference: See REST Server in the Configuration Reference.