Initial working version adapted from FW/1 for Clojure example

This commit is contained in:
Sean Corfield
2019-04-25 14:41:27 -07:00
commit dc51a81bcc
16 changed files with 929 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/.cpcache
/usermanager_db

14
LICENSE Normal file
View File

@ -0,0 +1,14 @@
Copyright (c) 2009-2016 Sean Corfield (see individual files for any
additional copyright holders)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

234
README.md Normal file
View File

@ -0,0 +1,234 @@
FW/1 in Clojure [![Join the chat at https://gitter.im/framework-one/fw1-clj](https://badges.gitter.im/framework-one/fw1-clj.svg)](https://gitter.im/framework-one/fw1-clj?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
===============
This was an interesting thought experiment based on my initial thinking that Clojure needed a very simple, convention-based web framework, like Framework One (FW/1) from the CFML world. I thought it would be a gentle on-ramp for CFML developers who were interested in trying Clojure -- and that part was successful in that it piqued the interest of a number of them to build toy applications with FW/1 for Clojure.
At World Singles, we started using FW/1 for Clojure in 2016, as a migration path from our FW/1 for CFML applications. For those applications, I found I either needed explicit Compojure routes (for our REST APIs) or a more flexible Ring middleware stack, or both. The convention-based views and cascading layouts just weren't useful in the real world programs we were building. In December 2016, I started to [think about refactoring FW/1 to more standard Ring middleware](https://github.com/framework-one/fw1-clj/blob/master/RING.md) and as I started the actual refactoring, I realized that FW/1 really added very little in terms of convenience but a fair bit in terms of non-standard behavior (controllers were not quite regular Ring handlers, CORS handling was custom, non-HTML responses were complex and custom, and HTML responses were full of magic).
By mimicking FW/1 for CFML, I'd lost a lot of the simplicity and elegance that web applications in Clojure can exhibit. That's why I'm sunsetting FW/1 for Clojure before it gets any traction. "So long, and thanks for all the fish!", as they say.
I've converted the [user manager example](https://github.com/framework-one/fw1-clj/tree/master/examples/usermanager) to no longer need FW/1 at all. Yes, there's a little bit more boilerplate in `main.clj` around creating and starting the web server, and it could easily be streamlined if you only wanted to use Jetty (or only http-kit). Yes, you now have to specify the routes explicitly instead of the magic that was there before -- but in any reasonable web application you will want that level of control over your "public API" (the URLs) and you will want to keep it decoupled from your handlers. Yes, you have to deal with the views and layouts yourself, but as you can see in [the `usermanager` controller's `after` function](https://github.com/framework-one/fw1-clj/blob/master/examples/usermanager/controllers/user.clj#L17-L24), it's just a few lines of simple code to deal with this. With those small trade-offs, you get the benefits of standard Ring handlers, composable middleware, and the freedom to choose how you start/stop your applications and how you deal with HTML templates. You can swap out Compojure for Bidi or something else, you can swap out Selmer for something else. If you're building a REST API, you can drop Selmer and rely on ring-json to turn your responses into JSON for you. The world becomes your Clojure-powered oyster!
Porting FW/1 from CFML to Clojure taught me a lot about the Ring ecosystem and also about Clojure's overall preference for simplicity, elegance, and composability. Time to move on.
The Original README
===================
This was based on a port from CFML to Clojure of Framework One (FW/1). Most concepts carry over but, like Clojure itself, the emphasis is on simplicity.
FW/1 in Clojure is based on [Ring](https://github.com/ring-clojure/ring), [Compojure](https://github.com/weavejester/compojure), and [Selmer](https://github.com/yogthos/Selmer).
FW/1 is a lightweight, (partially) convention-based MVC framework.
The easiest way to get started with FW/1 is to use the
[fw1-template](https://github.com/framework-one/fw1-template) template
for Boot. The template can create a basic FW/1 skeleton
project for you that "just works" and provides the directory structure
and some basic files for you to get started with.
Assuming you have [Boot](http://boot-clj.com) installed, you can create a new skeleton FW/1 app like this:
boot -d seancorfield/boot-new new -t fw1 -n myfw1app
This will create a skeleton FW/1 app in the `myfw1app` folder. You can run it like this:
cd myfw1app
boot run -p 8111
If you omit the `-p` / `--port` argument, it will default to port 8080, unless overridden by an environment variable:
PORT=8111 boot run
URL Structure
-------------
In a FW/1 application, Controller functions and Views are automatically located based on standard patterns - with site sections and items within each section. Layouts are applied, if provided, in a cascade from item to section to site. You specify the site and item as a namespaced keyword `:section/item` and FW/1 will locate Controllers, Views, and Layouts based on that.
Actual URL route processing is handled via Compojure and FW/1 provides a default set of routes that should serve most purposes. The `usermanager` example leverages that default in the `fw1-handler` function:
``` clojure
(defn fw1-handler
"Build the FW/1 handler from the application. This is where you can
specify the FW/1 configuration and the application routes."
[application]
(fw1/default-handler application
{:application-key "usermanager"
:home "user.default"}))
```
The default handler behavior is equivalent to this:
``` clojure
(defn fw1-handler
"Build the FW/1 handler from the application. This is where you can
specify the FW/1 configuration and the application routes."
[application]
(let-routes [fw1 (fw1/configure-router {:application application
:application-key "usermanager"
:home "user.default"})]
(route/resources "/")
(ANY "/" [] (fw1))
(context "/:section" [section]
(ANY "/" [] (fw1 (keyword section)))
(ANY "/:item" [item] (fw1 (keyword section item)))
(ANY "/:item/:id{[0-9]+}" [item] (fw1 (keyword section item))))
(route/not-found "Not Found"))
```
As above, the handler is initialized with an application Component. It obtains a router from FW/1 by providing configuration for FW/1. It then defines routes using Compojure, starting with a general `resources` route, followed by a few standard route patterns that map to `:section/item` keywords.
Project Structure
-----------------
The standard file structure for a FW/1 application is:
* `resources/`
* `public/` - folder containing web-accessible (public) assets
* `src/`
* `app_key/` - matches the `:application-key` value specified in the configuration above.
* `controllers/` - contains a `.clj` file for each _section_ that needs business logic.
* `layouts/` - contains per-_item_, per-_section_ and per-site layouts as needed.
* `views/` - contains a folder for each _section_, containing an HTML view for each _item_.
* `main.clj` - the entry point for your application.
Your Model can be anywhere since it will be `require`d into your controller namespaces as needed.
Request Lifecycle
-----------------
Controllers can have _before(rc)_ and _after(rc)_ handler functions that apply to all requests in a _section_.
A URL of `/section/item` will cause FW/1 to call:
* `(controllers.section/before rc)`, if defined.
* `(controllers.section/item rc)`, if defined.
* `(controllers.section/after rc)`, if defined.
A handler function should return the `rc`, updated as necessary. Strictly speaking, FW/1 will also call any `:before` / `:after` handlers defined in the configuration -- see below. This sequence of controller calls will be interrupted if `(abort rc)` has been called.
If one of the `render-xxx` functions has been called, FW/1 will render the data as specified. If the `redirect` function has been called, FW/1 will respond with a redirect (a `Location` header containing a URL). Otherwise FW/1 will look for an HTML view template:
* `views/section/item.html`
The suffix can be controlled by the `:suffix` configuration option but defaults to `"html"`.
FW/1 looks for a cascade of layouts (again, the suffix configurable):
* `layouts/section/item.html`,
* Replacing `{{body}}` with the view (and not calling any transforms).
* `layouts/section.html`,
* Replacing `{{body}}` with the view so far.
* `layouts/default.html`,
* Replacing `{{body}}` with the view so far. The `:layout` configuration is ignored.
Rendering Data
--------------
If a `render-xxx` function is called, FW/1 will return a response that has the given status code (or 200 if none were specified) and the set a `Content-Type` header based on the data type specified for the rendering. The body will be the expression, converted per the data type.
The following content types are built-in:
* `:html` - `text/html; charset=utf-8`
* `:json` - `application/json; charset=utf-8`
* `:raw-json` - `application/json; charset=utf-8`
* `:text` - `text/plain; charset=utf-8`
* `:xml` - `text/xml; charset=utf-8`
By default, `:html`, `:raw-json`, and `:text` render the data as-is. `:json` uses Cheshire's `generate-string` to render the data as a JSON-encoded string, using the `:json-config` settings from the FW/1 configuration if specified. `:xml` uses `clojure.data.xml`'s `sexp-as-element` and then `emit` to render the data as an XML-encoded string.
You can override these in the FW/1 config, via the `:render-types` key. You can also add new data types that way. Each data type is specified by a keyword (as above) and a map with two keys:
* `:type` - the content type string to use (as shown above).
* `:body` - a function that accepts two arguments - the FW/1 configuration and the data to render - and returns a string that represents the converted value of the data.
Convenience functions are provided for the five built-in data types: `render-html`, `render-json`, `render-raw-json`, `render-text`, and `render-xml`. The `render-data` function can be used for custom data types. In particular, `render-data` can be used to specify runtime data rendering:
(fw1/render-data rc render-fn expr)
(fw1/render-data rc status render-fn expr)
The `render-fn` should be a function of two arities. When called with no arguments, it should either return a content type string for the rendering, or one of the known data types (including custom ones from the `:render-types` configuration) and that data type's content type string will be used. When called with two arguments - the FW/1 configuration and the data to render - it should return a string that represents the converted value of the data.
As a convenience, you can use the `render-by` function to turn your `render-fn` into a function that behaves like the built-in `render-xxx` data type renderers:
(def render-custom (fw1/render-by my-render-fn))
...
(render-custom rc expr)
Framework API
-------------
Any controller function also has access to the the FW/1 API (after `require`ing `framework.one`):
* `(abort rc)` - abort the controller lifecycle -- do not apply any more controllers (of the :before "before" item "after" :after lifecycle).
* `(cookie rc name)` - returns the value of `name` from the cookie scope.
* `(cookie rc name value)` - sets `name` to `value` in the cookie scope, and returns the updated `rc`.
* `(event rc name)` - returns the value of `name` from the event scope (`:action`, `:section`, `:item`, or `:config`).
* `(event rc name value)` - sets `name` to `value` in the event scope, and returns the updated `rc`.
* `(flash rc name value)` - sets `name` to `value` in the flash scope, and returns the updated `rc`.
* `(header rc name)` - return the value of the `name` HTTP header, or `nil` if no such header exists.
* `(header rc name value)` - sets the `name` HTTP header to `value` for the response, and returns the updated `rc`.
* `(parameters rc)` - returns just the form / URL parameters from the request context (plus whatever parameters have been added by controllers). This is useful when you want to iterate over the data elements without worrying about any of the 'special' data that FW/1 puts in `rc`.
* `(redirect rc url)` or `(redirect rc status url)` - returns `rc` containing information to indicate a redirect to the specified `url`.
* `(redirecting? rc)` - returns `true` if the current request will redirect, i.e., `(redirect rc ...)` has been called.
* `(reload? rc)` - returns `true` if the current request includes URL parameters to force an application reload.
* `(remote-addr rc)` - returns the IP address of the remote requestor (if available). Checks the `"x-forwarded-for"` header (set by load balancers) then Ring's `:remote-addr` field.
* `(render-by render-fn)` - a convenience to produce a `render-xxx` function. See the last section of **Rendering Data** above for details.
* `(render-data rc type data)` or `(render rc status type data)` - low-level function to tell FW/1 to render the specified `data` as the specified `type`, optionally with the specified `status` code. Prefer the `render-xxx` convenience functions that follow is you are rendering standard data types.
* `(render-xxx rc data)` or `(render-xxx rc status data)` - render the specified `data`, optionally with the specified `status` code, in format _xxx_: `html`, `json`, `raw-json`, `text`, `xml`.
* `(rendering? rc)` - returns `true` if the current request will render data (instead of a page), i.e., `(render-xxx rc ...)` has been called.
* `(ring rc)` - returns the original Ring request.
* `(ring rc req)` - sets the Ring request data. Intended to be used mostly for testing controllers, to make it easier to set up test `rc` data.
* `(servlet-request rc)` - returns a "fake" `HttpServletRequest` object that delegates `getParameter` calls to pull data out of `rc`, as well as implementing several other calls (delegating to the Ring request data); used for interop with other HTTP-centric libraries.
* `(session rc name)` - returns the value of `name` from the session scope.
* `(session rc name value)` - sets `name` to `value` in the session scope, and returns the updated `rc`.
* `(to-long val)` - converts `val` to a long, returning zero if it cannot be converted (values in `rc` come in as strings so this is useful when you need a number instead and zero can be a sentinel for "no value").
The following symbols from Selmer are exposed as aliases via the FW/1 API:
* `add-tag!`, `add-filter!`
Application Startup & Configuration
-----------------------------------
As noted above, you can start the server on port 8080, running the User Manager example if you cloned this repository, with:
boot run
You can specify a different port like this:
boot run -p 8111
or:
PORT=8111 boot run
In your main namespace -- `main.clj` in the example here -- the call to `(fw1/configure-router)` can be passed configuration parameters either
as a map (preferred) or as an arbitrary number of inline key / value pairs (legacy support):
* `:after` - a function (taking / returning `rc`) which will be called after invoking any controller
* `:application-key` - the namespace prefix for the application, default none.
* `:before` - a function (taking / returning `rc`) which will be called before invoking any controller
* `:default-section` - the _section_ used if none is present in the URL, default `"main"`.
* `:default-item` - the _item_ used if none is present in the URL, default `"default"`.
* `:error` - the action - _"section.item"_ - to execute if an exception is thrown from the initial request, defaults to `:default-section` value and `"error"` _[untested]_.
* `:home` - the _"section.item"_ pair used for the / URL, defaults to `:default-section` and `:default-item` values.
* `:json-config` - specify formatting information for Cheshire's JSON `generate-string`, used in `render-json` (`:date-format`, `:ex`, `:key-fn`, etc).
* `:lazy-load` - boolean, whether controllers should be lazily loaded. Default is false and all files in the `controllers` will be loaded just once at startup. When true, each controller is loaded when it is first requested, and if a request is a reload (see below) then the controller for that request is fully reloaded. `:lazy-load true` is useful for development, but should be turned off in production. _Note: in versions prior to 0.10.0, lazy loading was the default._
* `:middleware-default-fn` - an optional function that will be applied to Ring's site defaults; note that by default we do **not** enable the XSRF Anti-Forgery middleware that is normally part of the site defaults since that requires session scope and client knowledge which is not appropriate for many uses of FW/1. Specify `#(assoc-in % [:security :anti-forgery] true)` here to opt into XSRF Anti-Forgery (you'll probably also want to change the `:session :store` from the in-memory default unless you have just a single server instance).
* `:middleware-wrapper-fn` - an optional function that will be applied as the outermost piece of middleware, wrapping all of Ring's defaults (and the JSON parameters middleware).
* `:options-access-control` - specify what an `OPTIONS` request should return (`:origin`, `:headers`, `:credentials`, `:max-age`).
* `:password` - specify a password for the application reload URL flag, default `"secret"` - see also `:reload`.
* `:reload` - specify an `rc` key for the application reload URL flag, default `:reload` - see also `:password`.
* `:reload-application-on-every-request` - boolean, whether to reload controller, view and layout components on every request (intended for development of applications).
* `:render-types` - an optional map of data types to replace or augment the built-in data rendering. See **Rendering Data** above for more details.
* `:routes` - a vector of hash maps, specifying route patterns and what to map them to (full documentation coming in due course).
* `:selmer-tags` - you can specify a map that is passed to the Selmer parser to override what characters are used to identify tags, filters
* `:suffix` - the file extension used for views and layouts. Default is `"html"`.
For example: `(fw1/configure-router {:default-section "hello" :default-item "world"})` will tell FW/1 to use `hello.world` as the default action.
License & Copyright
===================
Copyright (c) 2015-2016 Sean Corfield.
Distributed under the Apache Source License 2.0.

17
deps.edn Normal file
View File

@ -0,0 +1,17 @@
{:paths ["resources" "src"]
:deps {org.clojure/clojure {:mvn/version "1.10.0"}
com.stuartsierra/component {:mvn/version "0.4.0"}
compojure {:mvn/version "1.6.1"}
ring {:mvn/version "1.7.1"}
ring/ring-defaults {:mvn/version "0.3.2"}
selmer {:mvn/version "1.12.12"}
org.xerial/sqlite-jdbc {:mvn/version "3.23.1"}
seancorfield/next.jdbc {:mvn/version "1.0.0-alpha11"}}
:aliases
{:test {}
:runner {}
:demo {:main-opts ["-m" "usermanager.main"]}}}

View File

@ -0,0 +1,29 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>User Manager</title>
<link rel="stylesheet" type="text/css" href="/assets/css/styles.css" />
</head>
<body>
<div id="container">
<h1>User Manager</h1>
<ul class="nav horizontal clear">
<li><a href="/">Home</a></li>
<li><a href="/user/list" title="View the list of users">Users</a></li>
<li><a href="/user/form" title="Fill out form to add new user">Add User</a></li>
<li><a href="/reset" title="Resets change tracking">Reset</a></li>
</ul>
<br />
<div id="primary">{{body}}</div>
</div>
<div class="font: smaller;">
You have made {{changes}} change(s) since the last reset!
</div>
</body>
</html>

View File

@ -0,0 +1,208 @@
/* ----- CONSISTENCY ----- */
html {font-size: 100.01%; overflow: -moz-scrollbars-vertical;} /* force Firefox browser window scrollbar to always appear */
html,body,address,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,form,fieldset,legend,p,blockquote,table,caption,th,td,img{margin:0;padding:0;}
abbr, acronym {border-bottom: 1px dotted #000; cursor: help; font-style: normal;}
a img, a:link img, a:visited img {border: none; border-width: 0; vertical-align: top;}
label {cursor: pointer;} /* label elements are clickable, demonstrate visually */
input {margin: 0;}
hr {display: none;}
/* - - - - - - - - - - - - - - - - - - - - - - - - - -
CORE IDENTIFIERS - font sizes 60,69,76,83,89
- - - - - - - - - - - - - - - - - - - - - - - - - - */
body, input, select, textarea, table {font-family: "Helvetica Neue", Helvetica, Arial, Geneva, sans-serif; font-size: 100%;}
body {font: 75%/1.5em "Helvetica Neue", Helvetica, Arial, Geneva, sans-serif; text-align: center;}
/* html > body {font-size: 12px;} explicitly state font size in px for smarter, px resizable browsers */
#container {margin: 0 auto 3em auto; text-align: left; width: 600px;}
#header {}
#navigation ul {padding: 0 2px 0 0;}
#navigation ul li a {font-weight: bold; padding: 11px 8px 14px 8px;}
#navigation ul.horizontal li {float: right; margin: 0; padding: 0 0 0 2px;}
#navigation ul.horizontal li.logo {float: left;}
#navigation ul.horizontal li.logo a {padding-left: 0;}
#navigation ul li.logo span {padding-left: 22px;}
#title {margin: 0 auto; padding: 1.5em 0;}
#title h1 {font-size: 150%; margin-bottom: 15px;}
#title h2 {font-size: 120%; font-weight: normal; line-height: 1.5em;}
#primary {padding: 1em 0;}
#secondary {}
#tertiary {}
#footer {margin: 0 auto; padding: 1.5em 0; text-align: center;}
#footer p {margin: 0 0 0.2em 0;}
/* ----- HEADINGS ----- */
h1, h2, h3, h4, h5, h6 {margin: 15px 0;}
h1 {font-size: 240%;}
h2 {font-size: 180%;}
h3 {font-size: 140%;}
h4 {font-size: 100%;}
h5 {font-size: 100%;}
h6 {font-size: 100%;}
/* ----- PARAGRAPHS ----- */
p {margin: 1em 0;}
/* ----- LISTS ----- */
ol, ul {line-height: normal; margin: 1.5em 0 1.7em 2em;}
ol li, ul li {line-height: 1.5em; margin: 0 0 0.5em 0;}
ul ul, ul ol, ol ul, ol ol {margin-top: 0; margin-bottom: 0;}
/* ----- TABLES ----- */
table {line-height: normal; table-layout: fixed; width: 100%;}
caption, th {caption-side: top; font-weight: bold;}
caption span, th, td {padding: 5px; text-align: left; vertical-align: top;}
/* ----- CLASS HELPERS ----- */
.hide {display: none;}
.icon {padding: 1px 0 1px 20px;}
.content {padding: 0 1em;}
.note {margin-top: 0; padding: 0 0 1em 0;}
.info, .success, .warning {padding: 1em;}
.accessible {height: 1px; left: -9999em; overflow: hidden; position: absolute; top: 0; width: 1px;}
.clear:after {clear: both; content: "."; display: block; height: 0; visibility: hidden;}
.clear {min-width: 0; display: inline-block; /* \*/display: block;}
* html .clear {/* \*/height: 1%;}
/* ----- NAVIGATION ----- */
ul.nav, ul.nav li, ul.nav ul, ul.nav a {display: block; line-height: normal; margin: 0; padding: 0;}
ul.nav, ul.nav li, ul.nav ul {list-style: none;}
ul.nav ul {display: none;}
ul.nav li {position: relative; z-index: 1;}
ul.nav li:hover {z-index: 999;}
ul.nav li:hover > ul {display: block; position: absolute;}
ul.nav li a {min-width: 0; padding: 4px 8px 5px 8px;} /* 1 more bottom px provides best visual balance */
ul.nav li a:link, ul.nav li a:visited, ul.nav li a:hover {position: relative; text-decoration: none;}
ul.horizontal li {float: left; width: auto;}
ul.horizontal li.alt {float: right;}
ul.horizontal ul {left: auto; right: auto; top: auto;}
ul.tabs {padding: 0 10px;}
ul.tabs li {float: left; margin: 0 6px 0 0;}
ul.tabs li.alt {float: right; margin: 0 0 0 6px;}
ul.tabs li a.selected {top: 1px;}
ul.tabs ul li {margin-right: 0;}
ul.vertical, ul.vertical ul {width: 15em;}
ul.vertical li {float: none;}
ul.vertical ul, ul.nav ul.vertical ul {left: 60%; margin-top: -0.5em; right: auto; top: auto;}
ul.wide {width: 100%;}
/* ----- FORMS ----- */
form {margin: 1.5em 0;}
form div.field, form div.control {padding: 1em 0;}
form div.field .required {cursor: help; font-style: normal; font-weight: bold;}
form div.help {line-height: 1.5em; margin-top: 3px;}
form.familiar div.field .label, form.familiar legend span {display: block; font-weight: bold; margin: 0 0 2px 0;}
form.unfamiliar div.field .label {display: block; float: left; margin: 0 10px 0 0; text-align: right;}
form.unfamiliar fieldset {position: relative;}
form.unfamiliar legend span {display: block; float: none; left: 0; position: absolute; text-align: right; top: 0;}
form.unfamiliar.scan div.field .label, form.unfamiliar.scan legend span {text-align: left;}
/* mulitple selection */
form ol.selection, form ol.selection li, form ol.selection li input {list-style: none; margin: 0; padding: 0;}
form ol.selection li label {display: block; padding: 0 0 5px 0;}
form ol.selection.scroll {height: 10em; overflow: auto;}
form ol.selection.scroll li label {padding: 3px 3px 3px 23px; text-indent: -19px;}
form ol.selection.scroll li label input {text-indent: -19px; width: 15px;}
/* field sizes */
input.small, select.small, textarea.small, form ol.selection.small {width: 15em;}
input.medium, select.medium, textarea.medium, form ol.selection.medium {width: 20em;}
input.large, select.large, textarea.large, form ol.selection.large {width: 25em;}
textarea.short, form ol.selection.scroll.short {height: 10em;}
textarea.tall, form ol.selection.scroll.tall {height: 20em;}
/* label widths */
form.unfamiliar.small .label, form.unfamiliar.small legend span {width: 12em;}
form.unfamiliar.medium .label, form.unfamiliar.medium legend span {width: 15em;}
form.unfamiliar.large .label, form.unfamiliar.large legend span {width: 18em;}
form.unfamiliar.small .help, form.unfamiliar.small .selection, form.unfamiliar.small div.control {margin-left: 13em;}
form.unfamiliar.medium .help, form.unfamiliar.medium .selection, form.unfamiliar.medium div.control {margin-left: 16em;}
form.unfamiliar.large .help, form.unfamiliar.large .selection, form.unfamiliar.large div.control {margin-left: 19em;}
/* - - - - - - - - - - - - - - - - - - - - - - - - -
Hacks - fixes rendering bugs (DO NOT REMOVE)
- - - - - - - - - - - - - - - - - - - - - - - - - */
.clear:after {clear: both; content: "."; display: block; height: 0; visibility: hidden;}
.clear {min-width: 0; display: inline-block; /* \*/display: block;}
* html .clear, * html form ol.selection li label {/* \*/height: 1%;}
* html form.unfamiliar .help {padding-left: 3px;}
* html form.unfamiliar ol.selection, * html form.unfamiliar select.selection {left: 3px; position: relative;}
* html form div.control input {overflow: visible; padding: 0 .25em; width: auto;} /* IE6 button width fix */
* html form div fieldset legend {margin-left: -7px;} /* for IE6 as legend is indented */
/* - - - - - - - - - - - - - - - - - - - - - - - - - -
Site Color Theme (background, border, color)
- - - - - - - - - - - - - - - - - - - - - - - - - - */
/* ----- CORE IDENTIFIERS ----- */
body {background-color: #F8F8F8; border-top: 4px solid #545454; color: #767676;}
#navigation ul li a {border: none; color: #B9B9B9;}
#navigation ul li a:hover {color: #445666;}
#navigation ul li.current a {color: #445666;}
#title h1, h3, h4 {color: #445666;}
#title h2 {color: #767676;}
#navigation {border-bottom: 1px solid #e9e9e9;}
#primary {border-top: 1px solid #e9e9e9; border-bottom: 1px solid #e9e9e9;}
/* ----- MISC CLASSES ----- */
.icon {background-position: 0 50%; background-repeat: no-repeat;}
.info, .note {border-bottom: 1px solid #e9e9e9;}
.success {background-color: #DEFFAF; border: 1px solid #ACD919;}
.warning {background-color: #FFF6BF; border: 1px solid #FFD324;}
.success, .warning {border-width: 1px 0;}
.button {background-color: #F0F0F0; border-color: #FFF #DCDCDC #DCDCDC #FFF; border-width: 1px;}
/* ----- LINKS ----- */
a, a:link, a:visited {color: #C44803;}
a:hover, a:active {color: #444;}
/* ----- HEADINGS ----- */
h1, h2, h3, h4, h1 a:link, h2 a:link, h3 a:link, h4 a:link,
h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited {
color: #74a050;
}
h1 a:hover, h2 a:hover, h3 a:hover, h4 a:hover,
h1 a:active, h2 a:active, h3 a:active, h4 a:active {
color: #445666;
}
/* ----- COLUMNAR LISTS ----- */
ul.columnar {background-color: #F4F4F4;}
ul.columnar li a:link, ul.columnar li a:visited {color: #444;}
ul.columnar li a:hover {background-color: #666; color: #FEF5C6;}
ul.columnar li div.content {background-color: #F4F4ED;}
.column-title {background-color: #58564F; color: #FFF;}
/* ----- NAVIGATION ----- */
ul.nav li {background-color: #F3F3F3; border: solid 1px #CCC;}
ul.nav li a:link, ul.nav li a:visited {color: #333;}
ul.nav li a:hover {background-color: #FFC;}
ul.nav li:hover, ul.nav li.sfhover, ul.tabs li a.selected, ul.tabs li a.selected:hover {background-color: #FFF;}
ul.nav ul {background-color: #FFF;} /* for IE/Win, no rendering problems elsewhere, prevents see through text */
ul.tabs li {border-width: 1px 1px 0 1px;}
ul.tabs li li {border-width: 1px;}
ul.horizontal {padding-left: 1px;} /* this rule and below are needed for borders */
ul.horizontal li {margin-bottom: -1px; margin-left: -1px;}
ul.horizontal ul.vertical li, ul.tabs ul.vertical li {margin-left: -1px;}
ul.horizontal ul.vertical li li, ul.tabs ul.vertical li li {margin-left: 0;}
ul.vertical li {margin-bottom: -1px;}
/* ----- PAGINATION ----- */
ul.paging {color: #444;}
ul.paging li a:link, ul.paging li a:visited {background-color: #ECE9D8; color: #333;}
ul.paging li a:hover, ul.paging li strong {background-color: #FFF6BF;}
/* ----- TABLES ----- */
table, caption, th, td {border-color: #8C959A; border-style: solid;}
table {border-width: 0 0 1px 1px}
caption {border-width: 1px 1px 0 1px;}
caption, tbody tr.alt {background-color: #F4F4F4;}
table.highlight tbody tr:hover, table.highlight tbody tr.hover {background-color: #DEFFAF;}
th, th a:link, th a:visited {background-color: #8C959A; color: #FFF;}
th a:hover {color: #CCC;}
th, td {border-width: 0 1px 0 0;}
/* ----- FORMS ----- */
form fieldset {border: none; border-width: 0;}
form legend {color: #333;}
form .focused {background-color: #F4F1E5;}
form ol.selection.scroll {background-color: #FFF; border: 1px solid #CCC;}
form ol.selection.scroll li.alt {background-color: #F4F4F4;}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
<p>{{message}}</p>

View File

@ -0,0 +1,43 @@
<h3>User Info</h3>
<form class="familiar medium" method="post" action="/user/save">
<input type="hidden" name="id" id="id"
value="{{user.addressbook/id}}"/>
<div class="field">
<label for="first_name" class="label">First Name:</label>
<input type="text" name="first_name" id="first_name"
value="{{user.addressbook/first_name}}"/>
</div>
<div class="field">
<label for="last_name" class="label">Last Name:</label>
<input type="text" name="last_name" id="last_name"
value="{{user.addressbook/last_name}}"/>
</div>
<div class="field">
<label for="email" class="label">Email:</label>
<input type="text" name="email" id="email"
value="{{user.addressbook/email}}"/>
</div>
<div class="field">
<label for="department_id" class="label">Department:</label>
<select name="department_id" id="department_id">{%
for d in departments
%}
<option value="{{d.department/id}}"{%
ifequal user.addressbook/department_id d.department/id
%} selected="selected"{%
endifequal %}>{{d.department/name}}</option>{%
endfor
%}
</select>
</div>
<div class="control">
<input type="submit" value="Save User"/>
</div>
</form>

View File

@ -0,0 +1,30 @@
<table border="0" cellspacing="0">
<col width="40" />
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Email</th>
<th>Department</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{% if users|empty? %}
<tr><td colspan="5">No users exist but <a href="/user/form">new ones can be added</a>.</td></tr>
{% else %}
{% for user in users %}
<tr>
<td><a href="/user/form/{{user.addressbook/id}}">{{user.addressbook/id}}</a></td>
<td class="name">
<a href="/user/form/{{user.addressbook/id}}">{{user.addressbook/last_name}},
{{user.addressbook/first_name}}</a>
</td>
<td class="email">{{user.addressbook/email}}</td>
<td class="department">{{user.department/name}}</td>
<td class="delete"><a href="/user/delete/{{user.addressbook/id}}">DELETE</a></td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>

View File

@ -0,0 +1,69 @@
;; copyright (c) 2019 Sean Corfield, all rights reserved
(ns usermanager.controllers.user
(:require [ring.util.response :as resp]
[selmer.parser :as tmpl]
[usermanager.model.user-manager :as model]))
(def ^:private changes
"Count the number of changes (since the last reload)."
(atom 0))
(defn before [req]
;; whatever needs doing at the start of the request
req)
(defn after [req]
(if (resp/response? req)
req
;; no response so far, render an HTML template
(let [data (assoc (:params req) :changes @changes)
view (:application/view req "default")
html (tmpl/render-file (str "views/user/" view ".html") data)]
(-> (resp/response (tmpl/render-file (str "layouts/default.html")
(assoc data :body [:safe html])))
(resp/content-type "text/html")))))
(defn reset-changes [req]
(reset! changes 0)
(assoc-in req [:params :message] "The change tracker has been reset."))
(defn default [req]
(assoc-in req [:params :message]
(str "Welcome to the User Manager application demo! "
"This uses just Compojure, Ring, and Selmer.")))
(defn delete-by-id [req]
(swap! changes inc)
(model/delete-user-by-id (get-in req [:params :id]))
(resp/redirect "/user/list"))
(defn edit [req]
(let [user (model/get-user-by-id (get-in req [:params :id]))]
(-> req
(update :params assoc
:user user
:departments (model/get-departments))
(assoc :application/view "form"))))
(defn get-users [req]
(let [users (model/get-users)]
(-> req
(assoc-in [:params :users] users)
(assoc :application/view "list"))))
(defn save [req]
(swap! changes inc)
(-> req
:params
;; get just the form fields we care about:
(select-keys [:id :first_name :last_name :email :department_id])
;; convert form fields to numeric:
(update :id #(some-> % not-empty Long/parseLong))
(update :department_id #(some-> % not-empty Long/parseLong))
;; qualify their names for domain model:
(->> (reduce-kv (fn [m k v] (assoc! m (keyword "addressbook" (name k)) v))
(transient {}))
(persistent!))
(model/save-user))
(resp/redirect "/user/list"))

165
src/usermanager/main.clj Normal file
View File

@ -0,0 +1,165 @@
;; copyright (c) 2019 Sean Corfield, all rights reserved
(ns usermanager.main
(:require [com.stuartsierra.component :as component]
[compojure.coercions :refer :all] ; for as-int
[compojure.core :refer :all] ; for GET POST and let-routes
[compojure.route :as route]
[ring.middleware.defaults :as ring-defaults]
[ring.util.response :as resp]
[usermanager.controllers.user :as user-ctl]
[usermanager.model.user-manager :as model]))
;; Implement your application's lifecycle here:
;; Although the application config is not used in this simple
;; case, it probably would be in the general case -- and the
;; application state here is trivial but could be more complex.
(defrecord Application [config ; configuration (unused)
state] ; behavior
component/Lifecycle
(start [this]
;; set up database if necessary
(model/setup-database)
(assoc this :state "Running"))
(stop [this]
(assoc this :state "Stopped")))
(defn my-application
"Return your application component, fully configured.
In this simple case, we just pass the whole configuration into
the application (:repl?)"
[config]
(map->Application {:config config}))
(defn my-middleware
"This middleware runs for every request and can execute before/after logic.
Note that if 'before' returns an HTTP response, we do not execute the handler
but after calling the handler, we always call 'after' -- it's up to that
function to decide what to do if the handler returns an HTTP response."
[handler]
(fn [req]
(let [resp (user-ctl/before req)]
(if (resp/response? resp)
resp
(user-ctl/after (handler req))))))
;; Helper for building the middleware:
(defn- add-app-component
"Middleware to add your application component into the request. Use
the same qualified keyword in your controller to retrieve it."
[handler application]
(fn [req]
(handler (assoc req :application/component application))))
;; This is Ring-specific, the specific stack of middleware you need for your
;; application. This example uses a fairly standard stack of Ring middleware
;; with some tweaks for convenience
(defn middleware-stack
"Given the application component and middleware, return a standard stack of
Ring middleware for a web application."
[app-component app-middleware]
(fn [handler]
(-> handler
(app-middleware)
(add-app-component app-component)
(ring-defaults/wrap-defaults (-> ring-defaults/site-defaults
;; disable XSRF for now
(assoc-in [:security :anti-forgery] false)
;; support load balancers
(assoc-in [:proxy] true))))))
;; This is the main web handler, that builds routing middleware
;; from the application component (defined above). The handler is passed
;; into the web server component (below).
(defn my-handler
"Given the application component, return middleware for routing."
[application]
(let-routes [wrap (middleware-stack application #'my-middleware)]
(GET "/" [] (wrap #'user-ctl/default))
;; horrible: application should POST to this URL!
(GET "/user/delete/:id{[0-9]+}" [id :<< as-int] (wrap #'user-ctl/delete-by-id))
;; add a new user:
(GET "/user/form" [] (wrap #'user-ctl/edit))
;; edit an existing user:
(GET "/user/form/:id{[0-9]+}" [id :<< as-int] (wrap #'user-ctl/edit))
(GET "/user/list" [] (wrap #'user-ctl/get-users))
(POST "/user/save" [] (wrap #'user-ctl/save))
;; this just resets the change tracker but really should be a POST :)
(GET "/reset" [] (wrap #'user-ctl/reset-changes))
(route/resources "/")
(route/not-found "Not Found")))
;; Standard web server component -- knows how to stop and start your chosen
;; web server... supports both jetty and http-kit as it stands:
;; lifecycle for the specified web server in which we run
(defrecord WebServer [handler-fn server port ; parameters
application ; dependencies
http-server shutdown] ; state
component/Lifecycle
(start [this]
(if http-server
this
(let [start-server (case server
:jetty (do
(require '[ring.adapter.jetty :as jetty])
(resolve 'jetty/run-jetty))
:http-kit (do
(require '[org.httpkit.server :as kit])
(resolve 'kit/run-server))
(throw (ex-info "Unsupported web server"
{:server server})))]
(assoc this
:http-server (start-server (handler-fn application)
(cond-> {:port port}
(= :jetty server)
(assoc :join? false)))
:shutdown (promise)))))
(stop [this]
(if http-server
(do
(case server
:jetty (.stop http-server)
:http-kit (http-server)
(throw (ex-info "Unsupported web server"
{:server server})))
(assoc this :http-server nil)
(deliver shutdown true))
this)))
(defn web-server
"Return a WebServer component that depends on the application.
The handler-fn is a function that accepts the application (Component) and
returns a fully configured Ring handler (with middeware)."
([handler-fn port] (web-server handler-fn port :jetty))
([handler-fn port server]
(component/using (map->WebServer {:handler-fn handler-fn
:port port :server server})
[:application])))
;; This is the piece that combines the generic web server component above with
;; your application-specific component defined at the top of the file:
(defn new-system
"Build a default system to run. In the REPL:
(def system (new-system 8888))
;; or
(def system (new-system 8888 :http-kit))
(alter-var-root #'system component/start)
(alter-var-root #'system component/stop)"
([port] (new-system port :jetty true))
([port server] (new-system port server true))
([port server repl?]
(component/system-map :application (my-application {:repl? repl?})
:web-server (web-server #'my-handler port server))))
(defn -main
[& [port server]]
(let [port (or port (get (System/getenv) "PORT" 8080))
port (cond-> port (string? port) Integer/parseInt)
server (or server (get (System/getenv) "SERVER" "jetty"))]
(println "Starting up on port" port "with server" server)
(-> (component/start (new-system port (keyword server) false))
;; wait for the web server to shutdown
:web-server :shutdown deref)))

View File

@ -0,0 +1,105 @@
;; copyright (c) 2019 Sean Corfield, all rights reserved
(ns usermanager.model.user-manager
(:require [next.jdbc :as jdbc]
[next.jdbc.sql :as sql]))
;; our database connection and initial data
(def ^:private my-db
"SQLite database connection spec."
{:dbtype "sqlite" :dbname "usermanager_db"})
(def ^:private departments
"List of departments."
["Accounting" "Sales" "Support" "Development"])
(def ^:private initial-user-data
"Seed the database with this data."
[{:first_name "Sean" :last_name "Corfield"
:email "sean@worldsingles.com" :department_id 4}])
;; database initialization
(defn setup-database
"Called at application startup. Attempts to create the
database table and populate it. Takes no action if the
database table already exists."
[]
(try
(jdbc/execute-one! my-db
["
create table department (
id integer primary key autoincrement,
name varchar(32)
)"])
(jdbc/execute-one! my-db
["
create table addressbook (
id integer primary key autoincrement,
first_name varchar(32),
last_name varchar(32),
email varchar(64),
department_id integer not null
)"])
(println "Created database and addressbook table!")
;; if table creation was successful, it didn't exist before
;; so populate it...
(try
(doseq [d departments]
(sql/insert! my-db :department {:name d}))
(doseq [row initial-user-data]
(sql/insert! my-db :addressbook row))
(println "Populated database with initial data!")
(catch Exception e
(println "Exception:" (ex-message e))
(println "Unable to populate the initial data -- proceed with caution!")))
(catch Exception e
(println "Exception:" (ex-message e))
(println "Looks like the database is already setup?"))))
(defn get-department-by-id
"Given a department ID, return the department record.
Uses in-memory lookup for non-changing data."
[id]
(sql/get-by-id my-db :department id))
(defn get-departments
"Return all available department records (in order)."
[]
(sql/query my-db ["select * from department order by name"]))
(defn get-user-by-id
"Given a user ID, return the user record."
[id]
(sql/get-by-id my-db :addressbook id))
(defn get-users
"Return all available users, sorted by name."
[]
(sql/query my-db
["
select a.*, d.name
from addressbook a
join department d on a.department_id = d.id
order by a.last_name, a.first_name
"]))
(defn save-user
"Save a user record. If ID is present and not zero, then
this is an update operation, otherwise it's an insert."
[user]
(let [id (:addressbook/id user)]
(if (and id (not (zero? id)))
;; update
(sql/update! my-db :addressbook
(dissoc user :addressbook/id)
{:id id})
;; insert
(sql/insert! my-db :addressbook
(dissoc user :addressbook/id)))))
(defn delete-user-by-id
"Given a user ID, delete that user."
[id]
(sql/delete! my-db :addressbook {:id id}))

View File

@ -0,0 +1,4 @@
;; copyright (c) 2019 Sean Corfield, all rights reserved
(ns usermanager.controllers.user-test
(:require [usermanager.controllers.user :refer :all]))

View File

@ -0,0 +1,4 @@
;; copyright (c) 2019 Sean Corfield, all rights reserved
(ns usermanager.main-test
(:require [usermanager.main :refer :all]))

View File

@ -0,0 +1,4 @@
;; copyright (c) 2019 Sean Corfield, all rights reserved
(ns usermanager.model.user-manager-test
(:require [usermanager.model.user-manager :refer :all]))