red-tape/basics/index.html @ d61bb4fbe61d
gundo.vim: Update documentation.
| author | Steve Losh <steve@stevelosh.com> |
|---|---|
| date | Sun, 21 Feb 2021 12:18:04 -0500 |
| parents | 69831b3947d7 |
| children | (none) |
<!DOCTYPE html> <html> <head> <meta charset="utf-8"/> <title>Basics / Red Tape</title> <link rel="stylesheet" href="../_dmedia/tango.css"/> <link rel="stylesheet/less" type="text/css" href="../_dmedia/style.less"/> <script src="../_dmedia/less.js" type="text/javascript"> </script> </head> <body class="content"> <div class="wrap"> <header><h1><a href="..">Red Tape</a></h1></header> <div class="markdown"> <h1 id="basics"><a href="">Basics</a></h1><p>Red Tape is a fairly simple library. It's designed to take raw form data (strings), validate it, and turn it into useful data structures.</p> <p>Red Tape does <em>not</em> handle rendering form fields into HTML. That's the job of your templating library, and you always need to customize <code><input></code> tags anyway.</p> <p>It's designed with Ring, Compojure, and friends in mind (though it's not limited to them) so let's take a look at a really simple application to see it in action.</p> <div class="toc"> <ul> <li><a href="#scaffolding">Scaffolding</a></li> <li><a href="#defining-the-form">Defining the Form</a></li> <li><a href="#using-the-form">Using the Form</a></li> <li><a href="#cleaners">Cleaners</a></li> <li><a href="#validation">Validation</a></li> <li><a href="#built-in-cleaners">Built-In Cleaners</a></li> <li><a href="#putting-it-all-together">Putting it All Together</a></li> <li><a href="#summary">Summary</a></li> </ul></div> <h2 id="scaffolding">Scaffolding</h2> <p>For this tutorial we'll create a Compojure app that allows people to submit comments. Let's sketch out the normal structure of that now:</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">ns </span><span class="nv">feedback</span> <span class="p">(</span><span class="nf">require</span> <span class="p">[</span><span class="nv">compojure.core</span> <span class="ss">:refer</span> <span class="ss">:all</span><span class="p">]</span> <span class="p">[</span><span class="nv">compojure.route</span> <span class="ss">:as</span> <span class="nv">route</span><span class="p">]</span> <span class="p">[</span><span class="nv">hiccup.page</span> <span class="ss">:refer</span> <span class="p">[</span><span class="nv">html5</span><span class="p">]]</span> <span class="p">[</span><span class="nv">ring.adapter.jetty</span> <span class="ss">:refer</span> <span class="p">[</span><span class="nv">run-jetty</span><span class="p">]]</span> <span class="p">[</span><span class="nv">ring.middleware.params</span> <span class="ss">:refer</span> <span class="p">[</span><span class="nv">wrap-params</span><span class="p">]]))</span> <span class="p">(</span><span class="kd">defn </span><span class="nv">page</span> <span class="p">[]</span> <span class="p">(</span><span class="nf">html5</span> <span class="p">[</span><span class="ss">:body</span> <span class="p">[</span><span class="ss">:form</span> <span class="p">{</span><span class="ss">:method</span> <span class="s">"POST"</span> <span class="ss">:action</span> <span class="s">"/"</span><span class="p">}</span> <span class="p">[</span><span class="ss">:label</span> <span class="s">"Who are you?"</span><span class="p">]</span> <span class="p">[</span><span class="ss">:input</span> <span class="p">{</span><span class="ss">:type</span> <span class="s">"text"</span> <span class="ss">:name</span> <span class="s">"name"</span><span class="p">}]</span> <span class="p">[</span><span class="ss">:label</span> <span class="s">"What do you want to say?"</span><span class="p">]</span> <span class="p">[</span><span class="ss">:textarea</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">"comment"</span><span class="p">}]]]))</span> <span class="p">(</span><span class="kd">defn </span><span class="nv">save-feedback</span> <span class="p">[</span><span class="nv">from</span> <span class="nv">comment</span><span class="p">]</span> <span class="c1">; In a real app this would save the string to a database, email</span> <span class="c1">; it to someone, etc.</span> <span class="p">(</span><span class="nb">println </span><span class="nv">from</span> <span class="s">"said:"</span> <span class="nv">comment</span><span class="p">))</span> <span class="p">(</span><span class="kd">defn </span><span class="nv">handle-get</span> <span class="p">[]</span> <span class="c1">; ...</span> <span class="p">)</span> <span class="p">(</span><span class="kd">defn </span><span class="nv">handle-post</span> <span class="p">[]</span> <span class="c1">; ...</span> <span class="p">)</span> <span class="p">(</span><span class="nf">defroutes</span> <span class="nv">app</span> <span class="p">(</span><span class="nf">GET</span> <span class="s">"/"</span> <span class="nv">request</span> <span class="p">(</span><span class="nf">handle-get</span> <span class="nv">request</span><span class="p">))</span> <span class="p">(</span><span class="nf">POST</span> <span class="s">"/"</span> <span class="nv">request</span> <span class="p">(</span><span class="nf">handle-post</span> <span class="nv">request</span><span class="p">)))</span> <span class="p">(</span><span class="k">def </span><span class="nv">handler</span> <span class="p">(</span><span class="nb">-> </span><span class="nv">app</span> <span class="nv">wrap-params</span><span class="p">))</span> <span class="p">(</span><span class="kd">defonce </span><span class="nv">server</span> <span class="p">(</span><span class="nf">run-jetty</span> <span class="o">#</span><span class="ss">'handler</span> <span class="p">{</span><span class="ss">:port</span> <span class="mi">3000</span><span class="p">}))</span> </pre></div> <p>That's about it for the boilerplate. The next step is to fill in the bodies of <code>handle-get</code> and <code>handle-post</code>. We'll need to do a few things:</p> <ul> <li>Render the initial comment form when the user first GETs the page.</li> <li>Validate incoming POST data to make sure it's sane.</li> <li>If the data isn't valid, inform the user and re-render the form nicely so they can fix it.</li> <li>Once we've got valid data, clean it up and send it off to be saved.</li> </ul> <p>This is where Red Tape comes in.</p> <h2 id="defining-the-form">Defining the Form</h2> <p>The main part of Red Tape is the <code>defform</code> macro. Let's define a simple feedback form:</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">ns </span><span class="nv">feedback</span> <span class="c1">; ...</span> <span class="p">(</span><span class="nf">require</span> <span class="p">[</span><span class="nv">red-tape.core</span> <span class="ss">:refer</span> <span class="p">[</span><span class="nv">defform</span><span class="p">]]))</span> <span class="p">(</span><span class="nf">defform</span> <span class="nv">feedback-form</span> <span class="p">{}</span> <span class="ss">:name</span> <span class="p">[]</span> <span class="ss">:comment</span> <span class="p">[])</span> </pre></div> <p><code>defform</code> takes a name, a map of form options, and a sequence of keywords and vectors representing fields. We'll look at each of those parts in more detail later, but for now let's actually <em>use</em> the form we've defined.</p> <h2 id="using-the-form">Using the Form</h2> <p>Defining a form results in a simple function that can be called with or without data. Let's sketch out how our handler functions will look:</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">defn </span><span class="nv">handle-get</span> <span class="p">([</span><span class="nv">request</span><span class="p">]</span> <span class="p">(</span><span class="nf">handle-get</span> <span class="nv">request</span> <span class="p">(</span><span class="nf">feedback-form</span><span class="p">)))</span> <span class="p">([</span><span class="nv">request</span> <span class="nv">form</span><span class="p">]</span> <span class="p">(</span><span class="nf">page</span><span class="p">)))</span> </pre></div> <p>There are a couple of things going on here.</p> <p>We've split the definition of <code>handle-get</code> into two pieces. The first piece takes a request, builds the default feedback form and forwards those along to the second piece, which actually renders the page. You'll see why we split it up like that shortly.</p> <p>Calling <code>(feedback-form)</code> without data returns a "result map" representing a fresh form. It will look like this:</p> <div class="codehilite"><pre><span class="p">{</span><span class="ss">:fresh</span> <span class="nv">true</span> <span class="ss">:valid</span> <span class="nv">false</span> <span class="ss">:arguments</span> <span class="p">{}</span> <span class="ss">:data</span> <span class="p">{}</span> <span class="ss">:results</span> <span class="nv">nil</span> <span class="ss">:errors</span> <span class="nv">nil</span><span class="p">}</span> </pre></div> <p>We'll see how to use this later. Let's move on to <code>handle-post</code>:</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">defn </span><span class="nv">handle-post</span> <span class="p">[</span><span class="nv">request</span><span class="p">]</span> <span class="p">(</span><span class="k">let </span><span class="p">[</span><span class="nv">data</span> <span class="p">(</span><span class="ss">:params</span> <span class="nv">request</span><span class="p">)</span> <span class="nv">form</span> <span class="p">(</span><span class="nf">feedback-form</span> <span class="nv">data</span><span class="p">)]</span> <span class="c1">; ...))</span> </pre></div> <p><code>handle-post</code> takes the raw HTTP POST data (from <code>(:params request)</code>) and passes it through the feedback form. Once again this results in a map. Assuming the user entered the name "Steve" and the comment "Hello!", the resulting map will look like this:</p> <div class="codehilite"><pre><span class="p">{</span><span class="ss">:fresh</span> <span class="nv">false</span> <span class="ss">:valid</span> <span class="nv">true</span> <span class="ss">:arguments</span> <span class="p">{}</span> <span class="ss">:data</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">"Steve"</span> <span class="ss">:comment</span> <span class="s">"Hello!"</span><span class="p">}</span> <span class="ss">:results</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">"Steve"</span> <span class="ss">:comment</span> <span class="s">"Hello!"</span><span class="p">}</span> <span class="ss">:errors</span> <span class="nv">nil</span><span class="p">}</span> </pre></div> <p>In a nutshell, this is all Red Tape does. You define form functions using <code>defform</code>, and those functions take in data and turn it into a result map like this.</p> <p>Let's add a bit of data cleaning to the form to get something more useful.</p> <h2 id="cleaners">Cleaners</h2> <p>Every field you define in a <code>defform</code> also gets a vector of "cleaners" associated with it. A cleaner is a vanilla Clojure function that takes one argument (the incoming value) and returns a new value (the outgoing result).</p> <p>Let's see this in action by modifying our form to strip leading and trailing whitespace from the user's name automatically:</p> <div class="codehilite"><pre><span class="p">(</span><span class="nf">defform</span> <span class="nv">feedback-form</span> <span class="p">{}</span> <span class="ss">:name</span> <span class="p">[</span><span class="nv">clojure.string/trim</span><span class="p">]</span> <span class="ss">:comment</span> <span class="p">[])</span> </pre></div> <p><code>clojure.string/trim</code> is just a normal Clojure function that trims off whitespace. Let's imagine that the user entered <code>Steve</code> as their name this time. Calling <code>(feedback-form data)</code> now results in the following map:</p> <div class="codehilite"><pre><span class="p">{</span><span class="ss">:fresh</span> <span class="nv">false</span> <span class="ss">:valid</span> <span class="nv">true</span> <span class="ss">:arguments</span> <span class="p">{}</span> <span class="ss">:data</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">" Steve "</span> <span class="ss">:comment</span> <span class="s">"Hello!"</span><span class="p">}</span> <span class="ss">:results</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">"Steve"</span> <span class="ss">:comment</span> <span class="s">"Hello!"</span><span class="p">}</span> <span class="ss">:errors</span> <span class="nv">nil</span><span class="p">}</span> </pre></div> <p>The <code>:data</code> in the result map still contains the raw data the user entered, but the <code>:results</code> have had their values passed through their cleaners first.</p> <p>You can define as many cleaners as you want for each field. The data will be threaded through them in order, much like the <code>-></code> macro. This lets you define simple cleaning functions and combine them as needed. For example:</p> <div class="codehilite"><pre><span class="p">(</span><span class="nf">defform</span> <span class="nv">feedback-form</span> <span class="p">{}</span> <span class="ss">:name</span> <span class="p">[</span><span class="nv">clojure.string/trim</span> <span class="nv">clojure.string/lower-case</span><span class="p">]</span> <span class="ss">:comment</span> <span class="p">[</span><span class="nv">clojure.string/trim</span><span class="p">])</span> <span class="p">(</span><span class="nf">feedback-form</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">" Steve "</span> <span class="ss">:comment</span> <span class="s">" Hello! "</span><span class="p">})</span> <span class="c1">; =></span> <span class="p">{</span><span class="ss">:fresh</span> <span class="nv">false</span> <span class="ss">:valid</span> <span class="nv">true</span> <span class="ss">:data</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">" Steve "</span> <span class="ss">:comment</span> <span class="s">" Hello! "</span><span class="p">}</span> <span class="ss">:results</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">"steve"</span> <span class="ss">:comment</span> <span class="s">"Hello!"</span><span class="p">}</span> <span class="c1">; ...</span> <span class="p">}</span> </pre></div> <p>Here we're trimming the name and then lowercasing it, and trimming the comment as well (but not lowercasing that).</p> <h2 id="validation">Validation</h2> <p>Cleaners also serve another purpose. If a cleaner function throws an Exception, the value won't progress any further, and the result map will be marked as invalid.</p> <p>Let's look at an example:</p> <div class="codehilite"><pre><span class="p">(</span><span class="nf">defform</span> <span class="nv">age-form</span> <span class="p">{}</span> <span class="ss">:age</span> <span class="p">[</span><span class="nv">clojure.string/trim</span> <span class="o">#</span><span class="p">(</span><span class="nf">Long.</span> <span class="nv">%</span><span class="p">)])</span> </pre></div> <p>If we call this form with a number, everything is fine:</p> <div class="codehilite"><pre><span class="p">(</span><span class="nf">age-form</span> <span class="p">{</span><span class="ss">:age</span> <span class="s">"27"</span><span class="p">})</span> <span class="c1">; =></span> <span class="p">{</span><span class="ss">:fresh</span> <span class="nv">false</span> <span class="ss">:valid</span> <span class="nv">true</span> <span class="ss">:data</span> <span class="p">{</span><span class="ss">:age</span> <span class="s">"27"</span><span class="p">}</span> <span class="ss">:results</span> <span class="p">{</span><span class="ss">:age</span> <span class="mi">27</span><span class="p">}</span> <span class="ss">:errors</span> <span class="nv">nil</span><span class="p">}</span> </pre></div> <p>But if we try to feed it garbage:</p> <div class="codehilite"><pre><span class="p">(</span><span class="nf">age-form</span> <span class="p">{</span><span class="ss">:age</span> <span class="s">"cats"</span><span class="p">})</span> <span class="c1">; =></span> <span class="p">{</span><span class="ss">:fresh</span> <span class="nv">false</span> <span class="ss">:valid</span> <span class="nv">false</span> <span class="ss">:data</span> <span class="p">{</span><span class="ss">:age</span> <span class="s">"cats"</span><span class="p">}</span> <span class="ss">:results</span> <span class="nv">nil</span> <span class="ss">:errors</span> <span class="p">{</span><span class="ss">:age</span> <span class="nv"><NumberFormatException</span><span class="err">:</span> <span class="nv">...></span><span class="p">}}</span> </pre></div> <p>There are a few things to see here. If any cleaner function throws an Exception, the resulting map will have <code>:valid</code> set to <code>false</code>.</p> <p>There will also be no <code>:results</code> entry in an invalid result. You only get <code>:results</code> if your <em>entire</em> form is valid. This is to help prevent you from accidentally using the results of a form with invalid data.</p> <p>The <code>:errors</code> map will map field names to the exception their cleaners threw. This happens on a per-field basis, so you can have separate errors for each field.</p> <p>Red Tape uses Slingshot's <code>try+</code> to catch exceptions, so if you want to you can use <code>throw+</code> to throw errors in an easier-to-manage way and they'll be caught just fine.</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">defn </span><span class="nv">ensure-not-immortal</span> <span class="p">[</span><span class="nv">age</span><span class="p">]</span> <span class="p">(</span><span class="k">if </span><span class="p">(</span><span class="nb">> </span><span class="nv">age</span> <span class="mi">150</span><span class="p">)</span> <span class="p">(</span><span class="nf">throw+</span> <span class="s">"I think you're lying!"</span><span class="p">)</span> <span class="nv">age</span><span class="p">))</span> <span class="p">(</span><span class="nf">defform</span> <span class="nv">age-form</span> <span class="p">{}</span> <span class="ss">:age</span> <span class="p">[</span><span class="nv">clojure.string/trim</span> <span class="o">#</span><span class="p">(</span><span class="nf">Long.</span> <span class="nv">%</span><span class="p">)</span> <span class="nv">ensure-not-immortal</span><span class="p">])</span> <span class="p">(</span><span class="nf">age-form</span> <span class="p">{</span><span class="ss">:age</span> <span class="s">"1000"</span><span class="p">})</span> <span class="c1">; =></span> <span class="p">{</span><span class="ss">:fresh</span> <span class="nv">false</span> <span class="ss">:valid</span> <span class="nv">false</span> <span class="ss">:data</span> <span class="p">{</span><span class="ss">:age</span> <span class="s">"1000"</span><span class="p">}</span> <span class="ss">:results</span> <span class="nv">nil</span> <span class="ss">:errors</span> <span class="p">{</span><span class="ss">:age</span> <span class="s">"I think you're lying!"</span><span class="p">}}</span> </pre></div> <p>Notice how <code>ensure-not-immortal</code> expected a number and not a String. This is fine because we still kept the <code>#(Long. %)</code> cleaner to handle that conversion.</p> <p>Finally, also notice that the <code>:data</code> entry in the result map is present and contains the data the user entered, even though it turned out to be invalid. We'll use this later when we want to rerender the form, so the user doesn't have to type all the data again.</p> <h2 id="built-in-cleaners">Built-In Cleaners</h2> <p>Red Tape contains a number of useful cleaner functions pre-defined in the <code>red-tape.cleaners</code> namespace.</p> <p>We'll use <code>red-tape.cleaners/non-blank</code> in this tutorial. <code>non-blank</code> is a simple cleaner that throws an exception if it receives an empty string, or otherwise passes through the data unchanged.</p> <p>Let's change the form to make sure that users don't try to submit an empty comment (but we'll still allow an empty name, in case someone wants to comment anonymously):</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">ns </span><span class="nv">feedback</span> <span class="c1">; ...</span> <span class="p">(</span><span class="nf">require</span> <span class="p">[</span><span class="nv">red-tape.cleaners</span> <span class="ss">:as</span> <span class="nv">cleaners</span><span class="p">]))</span> <span class="p">(</span><span class="nf">defform</span> <span class="nv">feedback-form</span> <span class="p">{}</span> <span class="ss">:name</span> <span class="p">[</span><span class="nv">clojure.string/trim</span><span class="p">]</span> <span class="ss">:comment</span> <span class="p">[</span><span class="nv">clojure.string/trim</span> <span class="nv">cleaners/non-blank</span><span class="p">])</span> </pre></div> <p>Notice that we trim whitespace <em>before</em> checking for a non-blank string, so a comment of all whitespace would result in an error.</p> <h2 id="putting-it-all-together">Putting it All Together</h2> <p>Now that we've seen how to clean and validate, we can finally connect the missing pieces to our feedback form.</p> <p>First we'll redefine our little HTML page to take the form as an argument, so we can pre-fill the inputs with any initial data:</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">defn </span><span class="nv">page</span> <span class="p">[</span><span class="nv">form</span><span class="p">]</span> <span class="p">(</span><span class="nf">html5</span> <span class="p">[</span><span class="ss">:body</span> <span class="p">[</span><span class="ss">:form</span> <span class="p">{</span><span class="ss">:method</span> <span class="s">"POST"</span> <span class="ss">:action</span> <span class="s">"/"</span><span class="p">}</span> <span class="p">[</span><span class="ss">:label</span> <span class="s">"Who are you?"</span><span class="p">]</span> <span class="p">[</span><span class="ss">:input</span> <span class="p">{</span><span class="ss">:type</span> <span class="s">"text"</span> <span class="ss">:name</span> <span class="s">"name"</span> <span class="ss">:value</span> <span class="p">(</span><span class="nf">get-in</span> <span class="nv">form</span> <span class="p">[</span><span class="ss">:data</span> <span class="ss">:name</span><span class="p">])}]</span> <span class="p">[</span><span class="ss">:label</span> <span class="s">"What do you want to say?"</span><span class="p">]</span> <span class="p">[</span><span class="ss">:textarea</span> <span class="p">{</span><span class="ss">:name</span> <span class="s">"comment"</span><span class="p">}</span> <span class="p">(</span><span class="nf">get-in</span> <span class="nv">form</span> <span class="p">[</span><span class="ss">:data</span> <span class="ss">:comment</span><span class="p">])]]]))</span> </pre></div> <p>Notice how we pull the values of each field out of the <code>:data</code> entry in the form result map.</p> <p>Now we can write the final GET handler:</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">defn </span><span class="nv">handle-get</span> <span class="p">([</span><span class="nv">request</span><span class="p">]</span> <span class="p">(</span><span class="nf">handle-get</span> <span class="nv">request</span> <span class="p">(</span><span class="nf">feedback-form</span><span class="p">)))</span> <span class="p">([</span><span class="nv">request</span> <span class="nv">form</span><span class="p">]</span> <span class="p">(</span><span class="nf">page</span> <span class="nv">form</span><span class="p">)))</span> </pre></div> <p>And the POST handler:</p> <div class="codehilite"><pre><span class="p">(</span><span class="kd">defn </span><span class="nv">handle-post</span> <span class="p">[</span><span class="nv">request</span><span class="p">]</span> <span class="p">(</span><span class="k">let </span><span class="p">[</span><span class="nv">data</span> <span class="p">(</span><span class="ss">:params</span> <span class="nv">request</span><span class="p">)</span> <span class="nv">form</span> <span class="p">(</span><span class="nf">feedback-form</span> <span class="nv">data</span><span class="p">)]</span> <span class="p">(</span><span class="k">if </span><span class="p">(</span><span class="ss">:valid</span> <span class="nv">form</span><span class="p">)</span> <span class="p">(</span><span class="k">let </span><span class="p">[{</span><span class="ss">:keys</span> <span class="p">[</span><span class="nb">name </span><span class="nv">comment</span><span class="p">]}</span> <span class="p">(</span><span class="ss">:results</span> <span class="nv">form</span><span class="p">)]</span> <span class="p">(</span><span class="nf">save-feedback</span> <span class="nb">name </span><span class="nv">comment</span><span class="p">)</span> <span class="p">(</span><span class="nf">redirect</span> <span class="s">"/"</span><span class="p">))</span> <span class="p">(</span><span class="nf">handle-get</span> <span class="nv">request</span> <span class="nv">form</span><span class="p">))))</span> </pre></div> <p>We use the form to process the raw data, and then examine the result. If it is valid, we save the feedback by using the cleaned <code>:results</code> and we're done.</p> <p>If it's <em>not</em> valid, we use the GET handler to re-render the form without redirecting. We pass along our <em>invalid</em> form as we do that, so that when the GET handler calls the <code>page</code> and uses the <code>:data</code> it will fill in the fields correctly so the user doesn't have to retype everything.</p> <h2 id="summary">Summary</h2> <p>That was a lot to cover, but now you've seen the basic Red Tape workflow! Most of the time you'll be doing what we just finished:</p> <ul> <li>Defining the form.</li> <li>Defining a GET handler that creates a blank form.</li> <li>Defining a GET handler that takes a form (either blank or invalid) and renders it to HTML.</li> <li>Defining a POST handler that runs data through the form, examines the result, and does the appropriate thing depending on whether it's valid or not.</li> </ul> <p>Now that you've got the general idea, it's time to look at a few topics in more detail. Start with the <a href="../input/">form input</a> guide.</p> </div> <footer><p>Made and <a href="http://sjl.bitbucket.org/d/">documented</a> with love by <a href="http://stevelosh.com">Steve Losh</a>.</p> <p><br/><a id="rochester-made" href="http://rochestermade.com" title="Rochester Made"><img src="http://rochestermade.com/media/images/rochester-made-dark-on-light.png" alt="Rochester Made" title="Rochester Made"/></a></p></footer> </div> </body> </html>