Releases: coast-framework/coast
Fix for 500 errors in production
There was a bug where 500 errors would return {:status 500 body: {:status 500 :body ""}}
instead of just what you put in the function π¬
New major version of coast π
Upgrading from eta
The theta release contains a number of bug fixes and API improvements to keep the code base simple.
Getting Started
The first step to upgrade from eta
to theta
is to update coast itself and add your database driver to deps.edn
; deps.edn
{:deps {coast-framework/coast.theta {:mvn/version "1.0.0"}
org.postgresql/postgresql {:mvn/version "42.2.5"}
; or for sqlite
org.xerial/sqlite-jdbc {:mvn/version "3.25.2"}}}
This is the first release where multiple databses (postgres and sqlite) are supported, but it also means that the database driver is up to you, not coast, similar to all of the other web frameworks out there.
The next step is to add another path to deps.edn
's :paths
key:
; deps.edn
{:paths ["db" "src" "resources"]}
The db folder is now where all database related files are stored instead of resources
Finally, re-download the coast
shell script just like if you were installing coast again for the first time. There is a reason it's coast.theta
and not coast.eta
curl -o /usr/local/bin/coast https://raw.githubusercontent.com/coast-framework/coast/master/coast && chmod a+x /usr/local/bin/coast
Migrations
There were a just a few changes to the way database migrations and database schema definitions are handled, so instead of confusing edn migrations which should still be supported, you can now define migrations with clojure and define the schema yourself as well. Plain SQL migrations still work and will always work.
Here's how the new migrations work
coast gen migration create-table-member email:text nick-name:text password:text photo:text
This generates a file in the db folder that looks like this:
(ns migrations.20190926190239-create-table-member
(:require [coast.db.migrations :refer :all]))
(defn change []
(create-table :member
(text :email)
(text :nick-name)
(text :password)
(text :photo)
(timestamps)))
There are more helpers for columns and references detailed in Migrations
Previously, this was a confusing mess of edn without any clear rhyme or reason. Hopefully this is an improvement over that. Running migrations is the same as before:
make db/migrate
This does not generate a resources/schema.edn
like before because the schema for relationships has been separated and is now defined by you, which means pull queries not only work with *
as in
(pull '* [:author/id 1])
; or
(q '[:pull *
:from author]) ; this will recursively pull the whole database starting from the author table
but this also means that pull queries and the rest of coast works with existing database schemas. Here's how
Schema
Before, the schema was tied to the database migrations, which seems like a great idea in theory, but in practice it made the migrations complex and brittle. Coast has moved away from that and has copied rails style schema definitions like so:
; db/associations.clj
(ns associations
(:require [coast.db.associations :refer [table belongs-to has-many tables]]))
(defn associations []
(tables
(table :member
(has-many :todos))
(table :todo
(belongs-to :member))))
This new associations file is essentially rails' model definitions all rolled into the same file because in coast you don't need models, just data in -> data out. These functions also build what was schema.edn
but you have a lot more control over the column names, the table names and foreign key names, so something like this would also work
; db/associations.clj
(ns associations
(:require [coast.db.associations :refer [table belongs-to has-many tables]]))
(defn associations []
(tables
(table :users
(primary-key "uid")
(has-many :todos :table-name "items"
:foreign-key "item_id"))
(table :todos
(primary-key "uid")
(belongs-to :users :foreign-key "uid"))))
There's also support for "shortcutting" through intermediate join tables which gives the same experience as a "many to many" relationship:
; db/associations.clj
(ns associations
(:require [coast.db.associations :refer [table belongs-to has-many tables]]))
(defn associations []
(tables
(table :member
(has-many :todos))
(table :todo
(belongs-to :member)
(has-many :tagged)
(has-many :tags :through :tagged))
(table :tagged
(belongs-to :todo)
(belongs-to :tag)
(table :tag
(has-many :tagged)
(has-many :todos :through :tagged)))))
Querying
Querying is largely the same, there are new helpers like
(coast/fetch :author 1)
This retrieves the whole row by primary key (assuming your primary key is id). Other notable differences are the requirement of a from
statement in all queries:
(coast/q '[:select * :from author])
Previously you could omit the from
and do this:
(coast/q '[:select author/*])
This may come back but I don't believe it works for this version. Another small change to pull queries inside of q
(coast/q '[:pull author/id
{:author/posts [post/title post/body]}
:from author]
Previously you had to surround the pull symbols with a vector, now you don't have to!
Another thing that's changed is transact
has been deprecated in favor of the much simpler insert/update/delete functions:
(coast/insert {:member/handle "sean"
:member/email "sean@swlkr.com"
:member/password "whatever"
:member/photo "/some/path/to/photo.jpg"})
(coast/update {:member/id 1
:member/email "me@seanwalker.xyz"})
(coast/delete {:member/id 1})
You can also pass vectors of maps as well and everything should work assuming all maps have the same columns and all maps in update
have a primary key column specified
Lesser known but will now work
(coast/execute! '[:update author
:set email = ?email
:where id = ?id]
{:email "new-email@email.com"
:id [1 2 3]})
Oh one last thing about insert/update/delete. They no longer return the value that was changed, they just return the number of records changed.
Exception Handling
There was quite a bit of postgres specific code related to raise/rescue, that is gone now since the postgres library isn't included anymore, which means any postgres exceptions like foreign key constraint violations or unique constraint violations will show up as exceptions in application code.
Routing
Routing has changed in a few ways, before you had to nest route vectors in another vector, which was confusing, now you call routes on the individual route vectors and coast does some formatting magic to get it into the right format.
(ns routes
(:require [coast]))
(def routes
(coast/routes
(coast/site-routes :components/layout
[:get "/" :home/index]
[:get "/posts" :post/index]
[:get "/posts/:id" :post/view]
[:get "/posts/build" :post/build]
[:post "/posts" :post/create]
[:get "/posts/:id/edit" :post/edit]
[:post "/posts/:id/edit" :post/change]
[:post "/posts/:id/delete" :post/delete])))
Before you had to wrap all vectors in another vector, now you don't it makes things a little cleaner. Also multiple layout support per batch of routes is easier as well since you no longer have to pass layout in app
.
Since the vector of vectors confusion is gone now, routes more naturally lend themselves to function helpers and resource-style url formats:
(ns routes
(:require [coast]))
(def routes
(coast/routes
(coast/site-routes :components/layout
[:resource :posts]
; is equal to all of the below routes
[:get "/posts" :post/index]
[:get "/posts/build" :post/build]
[:get "/posts/:id" :post/view]
[:post "/posts" :post/create]
[:get "/posts/:id/edit" :post/edit]
[:post "/posts/:id/edit" :post/change]
[:post "/posts/:id/delete" :post/delete])))
Views
Views have changed quite a bit, previous versions of coast treated code files like controllers that return html and that's back again, so before each file was separated in view/action function pairs in folders for each "action" that's not the case any more, the default layout for code is now this:
; src/<table>.clj
(defn index [request])
(defn view [request])
(defn build [request])
(defn create [request])
(defn edit [request])
(defn change [request])
(defn delete [request])
index
and view
correspond to a list/table page and a single row page.
build
and create
correspond to a new database row form page and a place to submit that form and insert the new row into the database
edit
and change
represent a form to edit an existing row and a place to submit that form and update the row in the db
delete
represents you guessed it a place to submit a delete form.
There are a few new helpers too, even though the old view helpers will still work:
(ns home
(:require [coast]))
(coast/redirect-to ::index)
This is a combination of redirect
and url-for
and it makes the handlers so muc...
Bugfixes & Improvements
Apparently postgres doesn't let you create a table named user
but it will let you make a table named "user"
which you then have to quote everywhere, so the two options were:
- Quote all table names everywhere (won't work with
defq
) - Warn when trying to create a table named
user
Went with option 2, so when you make a migration like this:
{:db/col :user/name :db/type "text"}
When you run db/migrate
you'll get an Exception
that looks like this:
Exception in thread "main" java.lang.Exception: user is a reserved word in postgres try a different name for this table
Bugfixes and Improvements
- b40b884 - Coerce timestamps from pulls to #inst's
- d015bb8 - Add # support to url-for
- 0b06ac3 - Don't require components ns to be in the project
- c289ce9 - Look for "/404" and "/500" routes
- c2b4b69 - Stop throwing reader is nil when assets don't exist
- 63c2b13 - Order deps alphabetically
- 39c752b - Add wrap-layout to coast ns
- 64d0e57 - Replace make server with make repl + a call to (server/-main)
- 770a7aa - Fix routes header
- dcca5a5 - Add quickstart to README
Fix a routing bug
- f96e1f8 - Eager load components and routes on startup
Routes wouldn't load unless you loaded everything in the REPL, not just the server namespace
Bug fixes and improvements
- 0f0299e - Change url-for to handle qualified keys
- fab6c6b - Add a sweet docstring
- 215a259 - Update README.md
- 69b85f5 - Wrap read template in
:div
- c8773ac - Don't escape dev exception page
- 9642d48 - Update list template
The biggest change here is url-for
now handles qualified keywords and automatically converts them to keywords with dashes! Inspiration was this issue here: #29
Before:
[:get "/todos/:todo-id" :todo.index/view]
(let [todo {:todo/id 1}]
(url-for :todo.index/view {:todo-id (:todo/id todo)}))
Now:
[:get "/todos/:todo-id" :todo.index/view]
(let [todo {:todo/id 1}]
(url-for :todo.index/view todo))
Thank you to @mgerlach-klick for kicking the tires π so hard and @NielsRenard for that README change!
π Fix for url-for
url-for
actually has a second arg, I swear
Fix up the list generator
It wasn't broken per se, but it was definitely not right in the head, in fact, it was exactly the same query from the read.clj
file. Whoops. Anyway, that's fixed now.
eta 1.0.0
There were quite a few changes, the biggest ones are:
html is escaped by default
All html is escaped by default now. I didn't realize this when I started, but hiccup 1.0.5 does NOT escape html by default, so you either had to litter your hiccup with calls to escape-html
or just accept all xss. Horrible. Apparently this was fixed in 2.0.0-alpha1 and it's been fixed for almost 2 years π± . No one told me. Anyway, say goodbye to xss.
coast/app takes one argument instead of two
The app
function (or entry point) whatever you want to call it, now only takes one argument: a map. This is a crazy thing to do since it's been taking routes and then a map for a long time. This is how it looks in previous versions of coast:
(def app (coast/app routes {:layout layout}))
This version (and all new versions)
(def app (coast/app {:routes routes :layout layout}))
It's a subtle change, but it's great, because now the app function is set up to do more things like separate site and api routesβ¦
separate html routes and json/api routes
I struggled with this for a while, how to get api routes working with coast's default middleware stack, where it tries to look for invalid forgery tokens and everything. The answer was to throw that old middleware stack away or at least move it and make room for the new api middleware stack that parses and responds to json by default. Here's how that looks:
(ns your-proj
(:require [coast]))
(def site-routes [[:get "/" :home.index/view]])
(def api-routes [[:get "/api" :api.home/index]])
(def app (coast/app {:routes/api api-routes :routes site-routes}))
separate insert and update* db functions
transact
is cool, but sometimes you know what you want and you just want to insert or update, so there they are. update*
not update
because update
is a core clojure function.
(insert {:todo/desc "" :todo/tag ""})
(update* {:todo/desc "" :todo/id 1})
everything is in the coast ns again
This was the main reason I wanted to make coast to begin with, I was tired of not only adding lots of deps and having to restart the repl, but also knowing which dep had which function to begin with, so the coast ns now has everything you need to make a website, here's an example from a recent I project I'm working on:
(ns home.index
(:require [components :refer [icon input label submit error]]
[coast :refer [form action-for redirect url-for rescue validate]]))
renamed internal-server-error to just server-error
This is a pretty minor thing, but I thought it was worth throwing in the release notes, internal-server-error
is the http status code, but it's quite a lot to type and of course it's internal, we're already writing code on the server
new generators!
The generators have also changed, so instead of coast gen controller <resource>
it's coast gen action <resource>
and here's how it looks:
; some routes which don't get added automatically, that'll probably be a future update
(def routes [[:get "/" :home.index/view :home]
[:get "/404" :error.not-found/view :404]
[:get "/500" :error.server-error/view :500]
[:get "/list-todos" :todo.list/view]
[:get "/new-todo" :todo.create/view]
[:post "/new-todo" :todo.create/action]
[:get "/view-todo/:id" :todo.read/view]
[:get "/edit-todo/:id" :todo.update/view]
[:post "/edit-todo/:id" :todo.update/action]
[:post "/delete-todo/:id" :todo.delete/action]])
Here's how the files are laid out:
βββ src
βΒ Β βββ components.clj
βΒ Β βββ error
βΒ Β βΒ Β βββ not_found.clj
βΒ Β βΒ Β βββ server_error.clj
βΒ Β βββ home
βΒ Β βΒ Β βββ index.clj
βΒ Β βββ routes.clj
βΒ Β βββ server.clj
βΒ Β βββ todo
βΒ Β βββ create.clj
βΒ Β βββ delete.clj
βΒ Β βββ list.clj
βΒ Β βββ read.clj
βΒ Β βββ update.clj
Here's how each file looks when it's generated
create
(ns todo.create
(:require [coast :refer [action-for first! flash form pull q raise redirect rescue transact url-for validate]]))
(defn view [req]
(form (action-for ::action)
[:div
[:label {:for "todo/ident"} "ident"]
[:input {:type "text" :name "todo/ident" :value (-> req :params :todo/ident)}]]
[:div
[:label {:for "todo/name"} "name"]
[:input {:type "text" :name "todo/name" :value (-> req :params :todo/name)}]]
[:input {:type "submit" :value "New todo"}]))
(defn action [{:keys [params] :as req}]
(let [[_ errors] (-> (validate params [[:required [:todo/ident :todo/name]]])
(select-keys [:todo/ident :todo/name])
(transact)
(rescue))]
(if (nil? errors)
(redirect (url-for :todo.list/view))
(view (merge req errors)))))
delete
(ns todo.delete
(:require [coast :refer [first! flash pull q delete redirect rescue url-for]]))
(defn action [{:keys [params] :as req}]
(let [[_ errors] (-> (q '[:select todo/id]
[:where [:todo/id (:id params)]])
(first!)
(delete)
(rescue))]
(if (nil? errors)
(redirect (url-for :todo.list/view))
(-> (redirect (url-for :todo.list/view))
(flash "Something went wrong!")))))
list
(ns todo.list
(:require [coast :refer [pull q url-for validate]]))
(defn view [{{:keys [id]} :params :as req}]
(let [rows (q '[:pull [:todo/ident :todo/name]])]
[:table
[:thead
[:tr
[:th "ident"]
[:th "name"]]]
[:tbody
(for [row rows]
[:tr
[:td (:todo/ident row)]
[:td (:todo/name row)]])]]))
read
(ns todo.read
(:require [coast :refer [first! q url-for]]))
(defn view [{{:keys [id]} :params :as req}]
(let [todo (first!
(q '[:select :todo/ident :todo/name
:where [:todo/id ?id]]
{:id id}))]
[:dl
[:dt "ident"]
[:dd (:todo/ident todo)]]
[:dl
[:dt "name"]
[:dd (:todo/name todo)]]))
update
(ns todo.update
(:require [coast :refer [action-for first! flash form pull q raise redirect rescue transact url-for validate]]))
(defn view [req]
(form (action-for ::action)
[:div
[:label {:for "todo/ident"} "ident"]
[:input {:type "text" :name "todo/ident" :value (-> req :params :todo/ident)}]]
[:div
[:label {:for "todo/name"} "name"]
[:input {:type "text" :name "todo/name" :value (-> req :params :todo/name)}]]
[:input {:type "submit" :value "Update todo"}]))
(defn action [{:keys [params] :as req}]
(let [[_ errors] (-> (validate params [[:required [:todo/ident :todo/name]]])
(select-keys [:todo/ident :todo/name])
(transact)
(rescue))]
(if (nil? errors)
(redirect (url-for :todo.read/view))
(view (merge req errors)))))
Hopefully this gives newcomers to coast a sense of how the code could look, if you decide to keep some of the generated stuff around.
So that's coast.eta 1.0.0! π
Assets as data
So remember before when you could pass your assets in with code and then it would all get wired together and minified on app startup and slapped in the request map with ring middleware? Yeah that wasn't very good. Since coast is a full stack framework, it can be in charge of what goes where and you don't have to worry about it. This is another good example of how a framework can be a better solution than a library. So the way before still works, that hasn't changed, here's what's new, you can make a resource file: resources/assets.edn
.
; resources/assets.edn
{"bundle.css" ["tachyons.min.css" "app.css"]
"bundle.js" ["jquery.min.js" "app.js"]}}
your js and css files are located in resources/public/js
and resources/public/css
(ns your-proj
(:require [coast.zeta :as coast]
[coast.components :refer [css js]]))
(def routes [[:get "/" `home]])
(defn layout [req body]
[:html
[:head
(css "bundle.css")]
[:body
(js "bundle.js")]])
(def opts {:layout layout})
(def app (coast/app routes opts))
(defn -main [& [port]]
(coast/server app {:port port}))
And now you can run a separate asset minification step in production: clj -m coast.assets
which will create a new file resources/assets.minified.edn
that will get included in an uberjar which you can then use for shipping. Phew, all of that was a round a bout way of saying I got it wrong and having separate asset minifcation/bundle steps really help.