Skip to content
caramelcookbook · 0.16.0
Browse documentation
Cookbook / Requests and responses

Read and write JSON.

Answer a generated Book resource’s actions as JSON, bind a JSON object to their contracts, and let an update check the version it was sent with If-Match.

Proved by scripts/check page-jsonTarget: 0.16.0
ContractValue
NeedsA generated Book resource, as in Create a resource
You will haveJSON answers from the generated actions, a version each book carries as ETag, updates that check If-Match, and a request spec for 200, 201, 412, 403, 400, 415 and 422
StatusRun end to end by scripts/check page-json
Proved byfrappe check · frappe corretto · frappe lint

Where the change goes

app/models/book.crThe version field
db/migrations/The version column
app/actions/books/show.crSends the version
app/actions/books/update.crChecks If-Match
spec/requests/books_json_spec.crJSON request examples

Ask for a JSON response

A generated action returns its result from handle and renders HTML in render. A client that sends Accept: application/json gets the result as JSON instead: the index answers records, the show record, and the create record with status 201. A failed contract or changeset answers 422 with an errors object, and the generated request spec already reads one book this way.

Read how actions answer JSON →

Give each book a version

A JSON client edits a book it read earlier. A version field lets the update refuse a change made from a stale copy: the show action sends the version as ETag, and the update checks the If-Match header against it before writing.

  1. Add the version field
    app/models/book.cr
    module App
      struct Book < SugarORM::Schema
        schema "books" do
          field id : Int64, primary: true
          field title : String
          field author : String
          field lock_version : Int32, version: true
          timestamps
        end
      end
    end

    version: true starts the column at 0 and adds one on every update. Read the version rules →

    Terminal · inside the application
    frappe db diff --name add_book_lock_version

    Expect: Prints Wrote and the path of one migration ending in _add_book_lock_version.cr, which adds "lock_version" integer NOT NULL DEFAULT 0 to books.

  2. Apply the migration
    Terminal · inside the application
    frappe migrate

    Expect: Prints Applied 1 migrations., then The database matches the declared schema.

  3. Send the version, then check it

    Replace the generated show and update actions. self.etag = version adds ETag: "0" to a successful JSON answer, and if_match?(version) is true with no If-Match header or one that names that version. Read the ETag and If-Match rules →

    app/actions/books/show.cr
    module App::Books
      struct Show < App::ApplicationAction
        contract do
          field id : Int64, min: 1
        end
    
        def handle(contract : Contract)
          record = App::Book.query.find(contract.id)
          return not_found("Book not found") unless record
          self.etag = record.lock_version
          {record: record}
        end
    
        def render(result)
          page "Book", Views::Books::Show.new(result[:record], csrf_token)
        end
      end
    end
    app/actions/books/update.cr
    module App::Books
      struct Update < App::ApplicationAction
        include Form
    
        contract do
          field id : Int64, min: 1
          field title : String
          field author : String
        end
    
        def handle(contract : Contract)
          record = App::Book.query.find(contract.id)
          return not_found("Book not found") unless record
          unless if_match?(record.lock_version)
            return render_errors({"_base" => [SugarORM::Wording.record_stale]}, 412)
          end
          changes = record.update(title: contract.title, author: contract.author)
          return render_form(contract.values, changes.errors, contract.id, 422) unless changes.saved?
          self.etag = changes.record.lock_version
          {record: changes.record}
        end
    
        def render(result)
          redirect_to(book_path(result[:record].id))
        end
      end
    end

    The browser form still works: it sends no If-Match, so the update writes, and the generated request spec keeps passing.

Bind a JSON object to the contract

With Content-Type: application/json, a top-level object binds to the contract by member name, and numbers bind as their source text. _csrf and _method are ordinary members: a JSON client sends X-CSRF-Token and the real method. Corretto’s json: sends the object and the header; it adds the origin, cookie and CSRF token as a browser would, and a header you pass replaces the one it adds.

Read how each value binds →Read the CSRF rules →
ConditionOutcome
Malformed JSONanswers 400
A JSON value that is not an objectanswers 422 with Expected a JSON object under _base
A missing or invalid fieldanswers 422 with the error under the field
A media type other than a form or application/jsonanswers 415
A forged X-CSRF-Tokenanswers 403
If-Match names another versionanswers 412 from the update above

An update whose row changed between its read and its write, with no If-Match to catch it, fails the changeset instead: stale? is true and the generated action answers 422. Read what a stale changeset does →

External callers need their own credential

An API that skips browser CSRF must declare an authenticator with ingress. Its session is empty and unsaved; authenticate with a bearer token or signature that a browser does not attach automatically. Receive a signed webhook →

Prove it

Prove the JSON behavior

Add the request spec beside the generated one, then run the checks.

spec/requests/books_json_spec.cr
require "../spec_helper"

describe "Books as JSON" do
  it "lists and shows books, with the version as an ETag" do
    Corretto.session do |client, db|
      App::Book.create(title: "Dune", author: "Frank Herbert")
      accept = {"Accept" => "application/json"}
      listed = client.get("/books", headers: accept)
      listed.should have_status(200)
      records = JSON.parse(listed.body)["records"].as_a
      records.map(&.["title"].as_s).should eq(["Dune"])
      book = App::Book.query.first!(db)
      shown = client.get("/books/#{book.id}", headers: accept)
      shown.should have_status(200)
      shown.should have_header("ETag", %("0"))
    end
  end

  it "creates a book from JSON and updates it when If-Match holds" do
    Corretto.session do |client, _db|
      accept = {"Accept" => "application/json"}
      book = {title: "Dune", author: "Frank Herbert"}
      created = client.post("/books", json: book, headers: accept)
      created.should have_status(201)
      path = "/books/#{JSON.parse(created.body)["record"]["id"].as_i64}"
      edited = {title: "Dune Messiah", author: "Frank Herbert"}
      stale = accept.merge({"If-Match" => %("7")})
      refused = client.patch(path, json: edited, headers: stale)
      refused.should have_status(412)
      refused.body.should eq(%({"errors":{"_base":["Record changed since you loaded it"]}}))
      current = accept.merge({"If-Match" => %("0")})
      updated = client.patch(path, json: edited, headers: current)
      updated.should have_status(200)
      updated.should have_header("ETag", %("1"))
      JSON.parse(updated.body)["record"]["title"].as_s.should eq("Dune Messiah")
    end
  end

  it "answers 403, 400, 415 and 422 to writes it cannot take" do
    Corretto.session do |client, _db|
      accept = {"Accept" => "application/json"}
      book = {title: "Dune", author: "Frank Herbert"}
      forged = accept.merge({"X-CSRF-Token" => "forged"})
      client.post("/books", json: book, headers: forged).should have_status(403)
      json = accept.merge({"Content-Type" => "application/json"})
      client.post("/books", body: "{", headers: json).should have_status(400)
      text = accept.merge({"Content-Type" => "text/plain"})
      client.post("/books", body: "Dune", headers: text).should have_status(415)
      missing = client.post("/books", json: {title: "Dune"}, headers: accept)
      missing.should have_status(422)
      missing.body.should eq(%({"errors":{"author":["is required"]}}))
      array = client.post("/books", json: ["Dune"], headers: accept)
      array.should have_status(422)
      JSON.parse(array.body)["errors"]["_base"].as_a.should eq(["Expected a JSON object"])
    end
  end
end
Terminal · inside the application
frappe check
frappe corretto
frappe lint

Passing: frappe check and frappe lint exit 0, and frappe corretto ends with Corretto: 1 of 1 workers passed after the three JSON examples and the generated ones pass.

Proved Run end to end by a check.

scripts/check page-json creates an application, generates the Book resource, applies the migration, copies these files at the steps above and runs the proof, and the website build holds every snippet on this page to those files.