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.
| Contract | Value |
|---|---|
| Needs | A generated Book resource, as in Create a resource |
| You will have | JSON 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 |
| Status | Run end to end by scripts/check page-json |
| Proved by | frappe check · frappe corretto · frappe lint |
Where the change goes
app/models/book.crThe version fielddb/migrations/The version columnapp/actions/books/show.crSends the versionapp/actions/books/update.crChecks If-Matchspec/requests/books_json_spec.crJSON request examplesAsk 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.
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.
- 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 endversion: truestarts 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_versionExpect: Prints
Wroteand the path of one migration ending in_add_book_lock_version.cr, which adds"lock_version" integer NOT NULL DEFAULT 0tobooks. - Apply the migration
Terminal · inside the application frappe migrateExpect: Prints
Applied 1 migrations., thenThe database matches the declared schema. - Send the version, then check it
Replace the generated show and update actions.
self.etag = versionaddsETag: "0"to a successful JSON answer, andif_match?(version)is true with noIf-Matchheader 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 endapp/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 endThe 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.
| Condition | Outcome |
|---|---|
| Malformed JSON | answers 400 |
| A JSON value that is not an object | answers 422 with Expected a JSON object under _base |
| A missing or invalid field | answers 422 with the error under the field |
A media type other than a form or application/json | answers 415 |
A forged X-CSRF-Token | answers 403 |
If-Match names another version | answers 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 →
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.
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
endfrappe check
frappe corretto
frappe lintPassing: 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.
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.