Skip to content

3. Plugin Development Guide

Sergei Kliuikov edited this page Apr 17, 2025 · 1 revision

This guide provides an in-depth walkthrough for creating EasyREST plugins. It covers:

  1. 🔗 Handshake & go‑plugin Basics
  2. 🔍 Plugin Interfaces: DBPlugin vs CachePlugin
  3. 📦 Project Structure & Binary Naming
  4. ⚙️ InitConnection: URI parsing & connection setup
  5. 🔧 BuildPluginContext: HTTP → ctx map breakdown
  6. 🔄 Transactions & Prefer header
  7. 🕰️ Timezone propagation
  8. 📊 CRUD Methods: arguments & behaviors
  9. 🚀 RPC (CallFunction)
  10. 📑 Schema Introspection (GetSchema)
  11. 🗃️ CachePlugin details
  12. 🔔 Putting It All Together

1. 🔗 Handshake & go‑plugin Basics

EasyREST uses HashiCorp’s go-plugin to load external binaries:

  • Handshake: Ensures client/server compatibility via a shared HandshakeConfig in easyrest.Handshake.

  • Serve: In your main(), register plugin roles:

    hplugin.Serve(&hplugin.ServeConfig{
      HandshakeConfig: easyrest.Handshake,
      Plugins: map[string]hplugin.Plugin{
        "db":    &easyrest.DBPluginPlugin{Impl: myDB},
        "cache": &easyrest.CachePluginPlugin{Impl: myCache}, // optional
      },
    })
  • Binary Naming Convention:
    Place your compiled binary in PATH as easyrest-plugin-<scheme>-<os>-<arch>.
    E.g. easyrest-plugin-mysql-linux-amd64 for MySQL on Linux AMD64.


2. 🔍 Plugin Interfaces

EasyREST defines two interfaces in github.com/onegreyonewhite/easyrest/plugin:

2.1 DBPlugin

type DBPlugin interface {
  InitConnection(uri string) error
  TableGet(userID, table string, selectFields []string,
           where map[string]any, ordering, groupBy []string,
           limit, offset int, ctx map[string]any) ([]map[string]any, error)
  TableCreate(userID, table string, data []map[string]any, ctx map[string]any) ([]map[string]any, error)
  TableUpdate(userID, table string, data map[string]any, where map[string]any, ctx map[string]any) (int, error)
  TableDelete(userID, table string, where map[string]any, ctx map[string]any) (int, error)
  CallFunction(userID, funcName string, data map[string]any, ctx map[string]any) (any, error)
  GetSchema(ctx map[string]any) (any, error)
}

2.2 CachePlugin

type CachePlugin interface {
  InitConnection(uri string) error
  Set(key, value string, ttl time.Duration) error
  Get(key string) (string, error)
}

3. 📦 Project Structure & Binary Naming

  • Directory layout:
    my-plugin/
    ├── go.mod
    ├── main.go
    └── plugin.go
    
  • Compile:
    GOOS=linux GOARCH=amd64 go build -o easyrest-plugin-myscheme-linux-amd64 main.go

4. ⚙️ InitConnection(uri string) error

Responsibilities:

  1. Validate URI prefix (myscheme://, redis://, etc.).
  2. Parse connection details (path, user, password, host, port, query params).
  3. Open connection (SQL driver, Redis client).
  4. Configure connection pool / client settings (timeouts, pool sizes).
  5. Ping or test connection.
Example: MySQL
func (m *mysqlPlugin) InitConnection(uri string) error {
  if !strings.HasPrefix(uri, "mysql://") {
    return errors.New("invalid MySQL URI")
  }
  // Parse URL
  parsed, err := url.Parse(uri)
  // Extract query params: maxOpenConns, timeout, ...
  // Build DSN: "user:pass@tcp(host:port)/dbname?..."
  m.db, err = sql.Open("mysql", dsn)
  m.db.SetMaxOpenConns(...)
  ctx, cancel := context.WithTimeout(...); defer cancel()
  return m.db.PingContext(ctx)
}
Example: Redis
func (r *redisCache) InitConnection(uri string) error {
  opts, err := redis.ParseURL(uri)
  r.client = redis.NewClient(opts)
  ctx, cancel := context.WithTimeout(...); defer cancel()
  return r.client.Ping(ctx).Err()
}

5. 🔧 BuildPluginContextctx Map Breakdown

EasyREST constructs a ctx map[string]any by:

func BuildPluginContext(r *http.Request) map[string]any {
  // 1. cfg := GetConfig()
  // 2. dbKey := mux.Vars(r)["db"]
  // 3. dbConfig := cfg.PluginMap[dbKey]
  // 4. Collect headers: map[key] = joined values
  // 5. Extract JWT claims → map[string]any with lowercase keys
  // 6. Parse `Prefer` header (see below)
  // 7. Default timezone if not in Prefer
  return map[string]any{
    "timezone":   timezone,    // string, e.g. "UTC" or "America/Los_Angeles"
    "headers":    headers,     // map[string]any of HTTP headers
    "claims":     plainClaims, // JWT claims map
    "jwt.claims": plainClaims, // alias for plugins expecting this key
    "method":     r.Method,    // "GET", "POST", ...
    "path":       r.URL.Path,  // request path
    "query":      r.URL.RawQuery, // raw query string
    "prefer":     prefer,      // map[string]any of Prefer keys
  }
}

5.1 Prefer Header Parsing

  • Header format: Prefer: timezone=UTC tx=rollback custom=foo
  • Keys lowercased.
  • timezone: overrides ctx["timezone"] → used for date/time contexts.
  • tx: if plugin’s DbTxEnd allows override, set to "commit" or "rollback".
  • All tokens stored in ctx["prefer"] map.

6. 🔄 Transactions & Prefer Logic

6.1 prefer.tx Effects

  • "commit": transaction is committed on success.
  • "rollback": transaction is explicitly rolled back, even on success.
  • Validation: any other value yields an error before executing.

6.2 Typical Flow

res, err := p.handleTransaction(ctx, func(tx *sql.Tx) (any,error) {
  // exec queries inside tx
})
func (p *plug) handleTransaction(ctxMap, operation) (any,error) {
  tx, _ := db.BeginTx(...)
  pref, err := getTxPreference(ctxMap)  // from ctx["prefer"]["tx"]
  if err != nil { return nil, err }
  injectContext(tx, ctxMap)              // see next
  result, err := operation(tx)
  if err != nil { tx.Rollback(); return nil, err }
  if pref=="rollback" { tx.Rollback() } else { tx.Commit() }
  return result, nil
}

7. 🕰️ Timezone Propagation

  • ctx["timezone"] holds the request’s timezone.

  • Plugins should pass this to the database session:

    • MySQL/SQLite: SET time_zone = ?
    • PostgreSQL: SELECT set_config('timezone', $1, true)
  • Ensures date/time functions (e.g. NOW()) respect client timezone.


8. 📊 CRUD Methods: Argument Details

8.1 TableGet

TableGet(
  userID      string,         // from JWT claim e.g. sub
  table       string,         // API path segment
  selectFields []string,      // e.g. ["id","name"]
  where       map[string]any, // filter conditions
  ordering    []string,       // ORDER BY values
  groupBy     []string,       // GROUP BY values
  limit       int,            // max rows
  offset      int,            // start position
  ctx         map[string]any, // plugin context
) ([]map[string]any, error)
  • where keys: column names; values: operator maps ({"eq":42}, {"like":"%foo%"}).
  • Plugins use easyrest.BuildWhereClause(where) to render WHERE SQL + args.

8.2 TableCreate

TableCreate(
  userID string,
  table  string,
  data   []map[string]any,   // each row as map[col]→value
  ctx    map[string]any,
) ([]map[string]any, error)
  • Return: slice of inserted rows (input echoed).
  • If rolled back, still return input data for dry‑run scenarios.

8.3 TableUpdate

TableUpdate(
  userID string,
  table  string,
  data   map[string]any,     // columns→new values
  where  map[string]any,     // filter
  ctx    map[string]any,
) (int, error)                // number of rows affected
  • Return zero if rollback preference or no rows.

8.4 TableDelete

TableDelete(
  userID string,
  table  string,
  where  map[string]any,
  ctx    map[string]any,
) (int, error)
  • Similar to update; returns affected count.

9. 🚀 RPC: CallFunction

CallFunction(
  userID   string,
  funcName string,
  data     map[string]any,  // RPC input args
  ctx      map[string]any,
) (any, error)
  • MySQL: stored procedures/functions discovered in InitConnection.
  • PostgreSQL: functions from information_schema.routines.
  • Build call:
    • MySQL: CALL name(?,…) or SELECT name(?,…).
    • Postgres: SELECT * FROM name($1,…).
  • Scan results via scanRows / processRows.
  • Handle:
    • Scalar: single row/single column → return scalar.
    • Composite: map of columns.
    • SETOF: slice of maps.

10. 📑 Schema Introspection: GetSchema

GetSchema(ctx map[string]any) (any, error)

Return value must be a Swagger 2.0‑compatible JSON schema object:

{
  "tables": {<tableName>: <JSON Schema>, …},
  "views":  {<viewName>: <JSON Schema>, …}, // SQLite/Postgres
  "rpc":    {<funcName>: [<inSchema>,<outSchema>], …}
}

JSON Schema Details

  • Object type with:
    • "properties": map of field → schema { type: "...", ... }
    • "required": array of required property names
    • Field schema:
      • type: "integer", "number", "string", or "boolean"
      • x-nullable: true if column allows null
      • readOnly: true for primary keys / auto‑columns
      • format: e.g. "byte" for BLOB/base64 in SQLite

11. 🗃️ CachePlugin Details

Implement caching either in SQL or Redis:

SQL Cache

  • Table easyrest_cache(key TEXT PRIMARY KEY, value TEXT, expires_at DATETIME)
  • Set: INSERT … ON CONFLICT DO UPDATE …
  • Get: SELECT value … WHERE expires_at > CURRENT_TIMESTAMP
  • Cleanup: goroutine deletes expired entries every minute.

Redis Cache

  • Use redis.Client.Set(ctx,key,value,ttl) & Get(ctx,key).
  • On Get return redis.Nil if not found → core handles cache miss.

12. 🔔 Putting It All Together

  1. Implement both plugin interfaces as needed.
  2. Compile with correct naming in $PATH.
  3. Configure in EasyREST’s config.yaml:
    plugins:
      mydb:
        uri: "myscheme://conn-details"
        enable_cache: true
        cache_name: mydb_cache
      mydb_cache:
        uri: "myscheme://conn-details?autoCleanup=true"
  4. Run easyrest-server --config config.yaml
  5. Hot‑reload with kill -SIGHUP $(pidof easyrest-server)

🚀 You’re now ready to build and integrate powerful EasyREST plugins with full context-awareness, transaction control, caching, and schema introspection!