Skip to content

Releases: coast-framework/coast

Fix for 500 errors in production

19 Mar 04:38
Compare
Choose a tag to compare

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 πŸŽ‰

19 Mar 04:37
Compare
Choose a tag to compare

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...

Read more

Bugfixes & Improvements

22 Dec 20:36
Compare
Choose a tag to compare

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:

  1. Quote all table names everywhere (won't work with defq)
  2. 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

30 Oct 15:39
Compare
Choose a tag to compare
  • 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

30 Oct 15:36
Compare
Choose a tag to compare
  • 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

03 Oct 01:01
Compare
Choose a tag to compare
  • 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

03 Oct 00:51
Compare
Choose a tag to compare

url-for actually has a second arg, I swear

Fix up the list generator

23 Sep 23:54
Compare
Choose a tag to compare

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

23 Sep 19:18
Compare
Choose a tag to compare

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

15 Aug 15:05
Compare
Choose a tag to compare

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.