├── .gitignore ├── .travis.yml ├── Dockerfile ├── FileServer.hs ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | dist-* 3 | cabal-dev 4 | *.o 5 | *.hi 6 | *.chi 7 | *.chs.h 8 | *.dyn_o 9 | *.dyn_hi 10 | .hpc 11 | .hsenv 12 | .cabal-sandbox/ 13 | cabal.sandbox.config 14 | *.prof 15 | *.aux 16 | *.hp 17 | *.eventlog 18 | .stack-work/ 19 | cabal.project.local 20 | *~ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Use new container infrastructure to enable caching 2 | sudo: false 3 | 4 | # Choose a lightweight base image; we provide our own build tools. 5 | language: c 6 | 7 | # Caching so the next build will be fast too. 8 | cache: 9 | directories: 10 | - $HOME/.ghc 11 | - $HOME/.cabal 12 | - $HOME/.stack 13 | 14 | matrix: 15 | include: 16 | - compiler: ": #Linux" 17 | addons: {apt: {packages: [libgmp,libgmp-dev]}} 18 | - compiler: ": #OS X" 19 | os: osx 20 | 21 | before_install: 22 | # Using compiler above sets CC to an invalid value, so unset it 23 | - unset CC 24 | 25 | # Get the stack executable on the PATH 26 | - mkdir -p ~/.local/bin 27 | - export PATH=$HOME/.local/bin:$PATH 28 | - | 29 | if [ `uname` = "Darwin" ] 30 | then 31 | travis_retry curl --insecure -L https://www.stackage.org/stack/osx-x86_64 | tar xz --strip-components=1 --include '*/stack' -C ~/.local/bin 32 | else 33 | travis_retry curl -L https://www.stackage.org/stack/linux-x86_64 | tar xz --wildcards --strip-components=1 -C ~/.local/bin '*/stack' 34 | fi 35 | 36 | # Make sure we can run our program 37 | script: 38 | - ./FileServer.hs sanity 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | MAINTAINER Michael Snoyman 3 | 4 | # Get dumb-init to avoid Ctrl-C issues. See: 5 | # http://engineeringblog.yelp.com/2016/01/dumb-init-an-init-for-docker.html 6 | ADD https://github.com/Yelp/dumb-init/releases/download/v1.1.3/dumb-init_1.1.3_amd64 /usr/local/bin/dumb-init 7 | RUN chmod +x /usr/local/bin/dumb-init 8 | 9 | # Set up Haskell Stack, the Haskell build tool. 10 | # Stack is the only dependency we have to run our application. 11 | # Once available, it will grab everything else we need 12 | # (compiler, libraries, etc). 13 | ADD https://get.haskellstack.org/get-stack.sh /usr/local/bin/ 14 | RUN sh /usr/local/bin/get-stack.sh 15 | 16 | # Copy over the source code and make it executable. 17 | COPY FileServer.hs /usr/local/bin/file-server 18 | RUN chmod +x /usr/local/bin/file-server 19 | 20 | # Create a new user account and directory to run from, and then 21 | # run everything else as that user. 22 | RUN useradd -m www && mkdir -p /workdir && chown www /workdir 23 | USER www 24 | 25 | # We run our application with "sanity" to force it to install all of 26 | # its dependencies during Docker image build time, making the Docker 27 | # image launch much faster. 28 | RUN /usr/local/bin/file-server sanity 29 | 30 | # We're all ready, now just configure our image to run the server on 31 | # launch from the correct working directory. 32 | CMD ["/usr/local/bin/dumb-init", "/usr/local/bin/file-server"] 33 | WORKDIR /workdir 34 | EXPOSE 8080 -------------------------------------------------------------------------------- /FileServer.hs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env stack 2 | {- stack 3 | --resolver lts-6.11 4 | --install-ghc 5 | runghc 6 | --package shakespeare 7 | --package wai-app-static 8 | --package wai-extra 9 | --package warp 10 | -} 11 | 12 | -- The code above is used for Haskell Stack's script interpreter 13 | -- feature. For more information, see: 14 | -- https://docs.haskellstack.org/en/stable/GUIDE/#script-interpreter 15 | -- 16 | -- Note how we explicitly list an LTS Haskell snapshot 17 | -- (https://www.stackage.org/lts-6.11) to ensure reproducibility. We 18 | -- then state which packages need to be present to run this code. 19 | 20 | -- Enable the OverloadedStrings extension, a commonly used feature. 21 | {-# LANGUAGE OverloadedStrings #-} 22 | 23 | -- We use the QuasiQuotes to embed Hamlet HTML templates inside 24 | -- our source file. 25 | {-# LANGUAGE QuasiQuotes #-} 26 | 27 | -- Import the various modules that we'll use in our code. 28 | import qualified Data.ByteString.Char8 as S8 29 | import qualified Data.ByteString.Lazy as L 30 | import Data.Functor.Identity 31 | import Network.HTTP.Types 32 | import Network.Wai 33 | import Network.Wai.Application.Static 34 | import Network.Wai.Handler.Warp 35 | import Network.Wai.Parse 36 | import System.Environment 37 | import System.FilePath 38 | import Text.Blaze.Html.Renderer.Utf8 39 | import Text.Hamlet 40 | 41 | -- | Entrypoint to our application 42 | main :: IO () 43 | main = do 44 | -- For ease of setup, we want to have a "sanity" command line 45 | -- argument. We'll see how this is used in the Dockerfile 46 | -- later. Desired behavior: 47 | -- 48 | -- * If we have the argument "sanity", immediately exit 49 | -- * If we have no arguments, run the server 50 | -- * Otherwise, error out 51 | args <- getArgs 52 | case args of 53 | ["sanity"] -> putStrLn "Sanity check passed, ready to roll!" 54 | [] -> do 55 | putStrLn "Launching application" 56 | -- Run our application (defined below) on port 8080 57 | run 8080 app 58 | _ -> error $ "Unknown arguments: " ++ show args 59 | 60 | -- | Our main application 61 | app :: Application 62 | app req send = 63 | -- Route the request based on the path requested 64 | case pathInfo req of 65 | -- "/": send the HTML homepage contents 66 | [] -> send $ responseBuilder 67 | status200 68 | [("Content-Type", "text/html; charset=utf-8")] 69 | (renderHtmlBuilder homepage) 70 | 71 | -- "/browse/...": use the file server to allow directory 72 | -- listings and downloading files 73 | ("browse":rest) -> 74 | -- We create a modified request that strips off the 75 | -- "browse" component of the path, so that the file server 76 | -- does not need to look inside a /browse/ directory 77 | let req' = req { pathInfo = rest } 78 | in fileServer req' send 79 | 80 | -- "/upload": handle a file upload 81 | ["upload"] -> upload req send 82 | 83 | -- anything else: 404 84 | _ -> send $ responseLBS 85 | status404 86 | [("Content-Type", "text/plain; charset=utf-8")] 87 | "Not found" 88 | 89 | -- | Create an HTML page which links to the /browse URL, and allows 90 | -- for a file upload 91 | homepage :: Html 92 | homepage = [shamlet| 93 | $doctype 5 94 | 95 | 96 | File server 97 | <body> 98 | <h1>File server 99 | <p> 100 | <a href=/browse/>Browse available files 101 | 102 | <form method=POST action=/upload enctype=multipart/form-data> 103 | <p>Upload a new file 104 | <input type=file name=file> 105 | <input type=submit> 106 | |] 107 | 108 | -- | Use the standard file server settings to serve files from the 109 | -- current directory 110 | fileServer :: Application 111 | fileServer = staticApp (defaultFileServerSettings ".") 112 | 113 | -- | Handle file uploads, storing the file in the current directory 114 | upload :: Application 115 | upload req send = do 116 | -- Parse the request body. We'll ignore parameters and just look 117 | -- at the files 118 | (_params, files) <- parseRequestBody lbsBackEnd req 119 | 120 | -- Look for the file parameter called "file" 121 | case lookup "file" files of 122 | -- Not found, so return a 400 response 123 | Nothing -> send $ responseLBS 124 | status400 125 | [("Content-Type", "text/plain; charset=utf-8")] 126 | "No file parameter found" 127 | -- Got it! 128 | Just file -> do 129 | let 130 | -- Determine the name of the file to write out 131 | name = takeFileName $ S8.unpack $ fileName file 132 | -- and grab the content 133 | content = fileContent file 134 | -- Write it out 135 | L.writeFile name content 136 | 137 | -- Send a 303 response to redirect back to the homepage 138 | send $ responseLBS 139 | status303 140 | [ ("Content-Type", "text/plain: charset=utf-8") 141 | , ("Location", "/") 142 | ] 143 | "Upload successful!" 144 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Michael Snoyman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # File server demo in a single Haskell file 2 | 3 | __Sneak peek__: Run `docker run --rm -p 8080:8080 4 | snoyberg/file-server-demo` and open 5 | [http://localhost:8080](http://localhost:8080). 6 | 7 | We've all been there. We need to write some non-trivial piece of 8 | functionality, and end up doing it in bash or perl because that's what 9 | we have on the server we'll be deploying to. Or because it's the 10 | language we can most easily rely on being present at a consistent 11 | version on our coworkers' machines. We'd rather use a different 12 | language and leverage more advanced, non-standard libraries, but we 13 | can't do that reliably. 14 | 15 | One option is to create static executables or to ship around Docker 16 | images. This is great for many use cases, and we are going to have a 17 | follow-up blog post about using Docker and Alpine Linux to make such 18 | static executables. But there are at least two downsides to this 19 | approach: 20 | 21 | * It's not possible to modify a static executable directly. You need 22 | to have access to the source code and the tool chain used to produce 23 | it. 24 | * The executable is tied to a single operating system; good luck 25 | getting your Linux executable to run on your OS X machine. 26 | 27 | Said another way: there are good reasons why people like to use 28 | scripting languages. This blog post is going to demonstrate doing some 29 | non-trivial work with Haskell, and do so with a fully reproducible and 30 | trivially installed toolchain, supported on multiple operating 31 | systems. 32 | 33 | ## Why Haskell? 34 | 35 | Haskell is a functional programming language with high performance, 36 | great safety features, and a large ecosystem of open source libraries 37 | to choose from. Haskell programs are high level enough to be readable 38 | and modifiable by non-experts, making it ideal for these kinds of 39 | shared scripts. If you're new to Haskell, learn more on 40 | [haskell-lang.org](https://haskell-lang.org/). 41 | 42 | ## The task 43 | 44 | We're going to put together a simple file server with upload 45 | capability. We're going to assume a non-hostile environment (like a 46 | corporate LAN with no external network access), and therefore not put 47 | in security precautions like upload size limits. We're going to use 48 | the relatively low-level Web Application Interface instead of a web 49 | framework. While it makes the code a bit longer, there's no magic 50 | involved. Common frameworks in Haskell include 51 | [Yesod](http://www.yesodweb.com/) and 52 | [Servant](http://haskell-servant.readthedocs.io/en/stable/). We're 53 | going to host this all with the blazingly fast Warp web server. 54 | 55 | ## Get Stack 56 | 57 | [Stack](https://haskellstack.org) is a cross-platform program for 58 | developing Haskell projects. While it has many features, in our case 59 | the most important bit is that it can: 60 | 61 | * Download a complete Haskell toolchain for your OS 62 | * Install Haskell libraries from a 63 | [curated package set](https://www.stackage.org/) 64 | * Run Haskell source files directly as a script (we'll show how below) 65 | 66 | Check out the 67 | [Get Started page on haskell-lang.org](https://haskell-lang.org/get-started) 68 | to get Stack on your system. 69 | 70 | ## The code 71 | 72 | You can see 73 | [the full source code on Github](https://github.com/snoyberg/file-server-demo/blob/master/FileServer.hs). Let's 74 | step through the important parts here. 75 | 76 | ### Script interpreter 77 | 78 | We start off our file with something that is distinctly _not_ Haskell 79 | code: 80 | 81 | ```haskell 82 | #!/usr/bin/env stack 83 | {- stack 84 | --resolver lts-6.11 85 | --install-ghc 86 | runghc 87 | --package shakespeare 88 | --package wai-app-static 89 | --package wai-extra 90 | --package warp 91 | -} 92 | ``` 93 | 94 | With this header, we've made our file executable from the shell. If 95 | you `chmod +x` the source file, you can run `./FileServer.hs`. The 96 | first line is a standard 97 | [shebang](https://en.wikipedia.org/wiki/Shebang_%28Unix%29). After 98 | that, we have a comment that provides Stack with the relevant command 99 | line options. These options tell it to: 100 | 101 | * Use the Haskell Long Term Support (LTS) 6.11 package set. From now 102 | through the rest of time, you'll be running against the same set of 103 | packages, so no worries about your code bitrotting! 104 | * Install GHC, the Glasgow Haskell Compiler. LTS 6.11 indicates what 105 | version of GHC is needed (GHC 7.10.3). Once again: no bitrot 106 | concerns! 107 | * `runghc` says we'd like to run a script with GHC 108 | * The rest of the lines specify which Haskell library packages we 109 | depend on. You can see a full list of available libraries in LTS 110 | 6.11 [on the Stackage server](https://www.stackage.org/lts-6.11) 111 | 112 | For more information on Stack's script interpreter support, see 113 | [the Stack user guide](https://docs.haskellstack.org/en/stable/GUIDE/#script-interpreter). 114 | 115 | ### Command line argument parsing 116 | 117 | Very often with these kinds of tools, we need to handle command line 118 | arguments. Haskell has some great libraries for doing this in an 119 | elegant way. For example, see 120 | [the optparse-applicative library tutorial](https://haskell-lang.org/library/optparse-applicative). However, 121 | if you want to go simple, you can also just use the `getArgs` function 122 | to get a list of arguments. We're going to add support for a `sanity` 123 | argument, which will allow us to sanity-check that running our 124 | application works: 125 | 126 | ```haskell 127 | main :: IO () 128 | main = do 129 | args <- getArgs 130 | case args of 131 | ["sanity"] -> putStrLn "Sanity check passed, ready to roll!" 132 | [] -> do 133 | putStrLn "Launching application" 134 | -- Run our application (defined below) on port 8080 135 | run 8080 app 136 | _ -> error $ "Unknown arguments: " ++ show args 137 | ``` 138 | 139 | ### Routing 140 | 141 | We're going to support three different routes in our application: 142 | 143 | * The `/browse/...` tree should allow you to get a directory listing 144 | of files in the current directory, and view/download individual 145 | files. 146 | * The `/upload` page accepts a file upload and writes the uploaded 147 | content to the current directory. 148 | * The homepage (`/`) should display an HTML page with a link to 149 | `/browse` and provide an HTML upload form targeting `/upload`. 150 | 151 | Thanks to pattern matching in Haskell, getting this to work is very 152 | straightforward: 153 | 154 | ``` haskell 155 | app :: Application 156 | app req send = 157 | -- Route the request based on the path requested 158 | case pathInfo req of 159 | -- "/": send the HTML homepage contents 160 | [] -> send $ responseBuilder 161 | status200 162 | [("Content-Type", "text/html; charset=utf-8")] 163 | (runIdentity $ execHtmlT homepage) 164 | 165 | -- "/browse/...": use the file server to allow directory 166 | -- listings and downloading files 167 | ("browse":rest) -> 168 | -- We create a modified request that strips off the 169 | -- "browse" component of the path, so that the file server 170 | -- does not need to look inside a /browse/ directory 171 | let req' = req { pathInfo = rest } 172 | in fileServer req' send 173 | 174 | -- "/upload": handle a file upload 175 | ["upload"] -> upload req send 176 | 177 | -- anything else: 404 178 | _ -> send $ responseLBS 179 | status404 180 | [("Content-Type", "text/plain; charset=utf-8")] 181 | "Not found" 182 | ``` 183 | 184 | The most complicated bit above is the path modification for the 185 | `/browse` tree, which is something a web framework would handle for us 186 | automatically. Remember: we're doing this low level to avoid extra 187 | concepts, real world code is typically even easier than this! 188 | 189 | ### Homepage content 190 | 191 | An area that Haskell really excels at is Domain Specific Languages 192 | (DSLs). We're going to use the 193 | [Hamlet](http://www.yesodweb.com/book/shakespearean-templates) for 194 | HTML templating. There are many other options in the Haskell world 195 | favoring other syntax, such as 196 | [Lucid library](https://www.stackage.org/package/lucid) (which 197 | provides a Haskell-based DSL), plus implementations of 198 | language-agnostic templates, like 199 | [mustache](https://www.stackage.org/package/mustache). 200 | 201 | Here's what our HTML page looks like in Hamlet: 202 | 203 | ``` haskell 204 | homepage :: Html () 205 | homepage = [shamlet| 206 | $doctype 5 207 | <html> 208 | <head> 209 | <title>File server 210 | <body> 211 | <h1>File server 212 | <p> 213 | <a href=/browse/>Browse available files 214 | 215 | <form method=POST action=/upload enctype=multipart/form-data> 216 | <p>Upload a new file 217 | <input type=file name=file> 218 | <input type=submit> 219 | |] 220 | ``` 221 | 222 | Note that Hamlet - like Haskell itself - uses significant whitespace 223 | and indentation to denote nesting. 224 | 225 | ### The rest 226 | 227 | We're not going to cover the rest of the code in the Haskell file. If 228 | you're interested in the details, please read the comments there, and 229 | feel free to ask questions about any ambiguous bits (hopefully the 230 | inline comments give enough clarity on what's going on). 231 | 232 | ## Running 233 | 234 | Download the `FileServer.hs` file contents (or copy-paste, or clone 235 | the repo), make sure the file is executable (`chmod +x 236 | FileServer.hs`), and then run: 237 | 238 | ``` shell 239 | $ ./FileServer.hs 240 | ``` 241 | 242 | If you're on Windows, you can instead run: 243 | 244 | ``` batch 245 | > stack FileServer.hs 246 | ``` 247 | 248 | That's correct: the same source file will work on POSIX systems and 249 | Windows as well. The only requirement is Stack and GHC support. Again, 250 | to get Stack on your system, please see the 251 | [Get Started page](https://haskell-lang.org/get-started). 252 | 253 | The first time you run this program, it will take a while to 254 | complete. This is because Stack will need to download and install GHC 255 | and necessary libraries to a user-local directory. Once complete, the 256 | results are kept on your system, so subsequent runs will be almost 257 | instantaneous. 258 | 259 | Once running, you can 260 | [view the app on localhost:8080](http://localhost:8080). 261 | 262 | ## Dockerizing 263 | 264 | Generally, I wouldn't recommend Dockerizing a source file like this; 265 | it makes more sense to Dockerize a compiled executable. We'll cover 266 | how to do that another time (though sneak preview: Stack has 267 | [built in support for generating Docker images](https://docs.haskellstack.org/en/stable/yaml_configuration/#image)). For 268 | now, let's actually Dockerize the source file itself, complete with 269 | Stack and the GHC toolchain. 270 | 271 | You can 272 | [check out the Dockerfile on Github](https://github.com/snoyberg/file-server-demo/blob/master/Dockerfile). That 273 | file may be slightly different from what I cover here. 274 | 275 | ```dockerfile 276 | FROM ubuntu:16.04 277 | MAINTAINER Michael Snoyman 278 | ``` 279 | 280 | Nothing too interesting... 281 | 282 | ```dockerfile 283 | ADD https://github.com/Yelp/dumb-init/releases/download/v1.1.3/dumb-init_1.1.3_amd64 /usr/local/bin/dumb-init 284 | RUN chmod +x /usr/local/bin/dumb-init 285 | ``` 286 | 287 | While interesting, this isn't Haskell-specific. We're just using an 288 | init process to get proper handling for signals. For more information, 289 | see 290 | [dumb-init's announcement blog post](http://engineeringblog.yelp.com/2016/01/dumb-init-an-init-for-docker.html). 291 | 292 | ```dockerfile 293 | ADD https://get.haskellstack.org/get-stack.sh /usr/local/bin/ 294 | RUN sh /usr/local/bin/get-stack.sh 295 | ``` 296 | 297 | Stack has a shell script available to automatically install it on 298 | POSIX systems. We just download that script and then run it. This is 299 | all it takes to have a Haskell-ready system set up: we're now ready to 300 | run script interpreter based files like our `FileServer.hs`! 301 | 302 | ```dockerfile 303 | COPY FileServer.hs /usr/local/bin/file-server 304 | RUN chmod +x /usr/local/bin/file-server 305 | ``` 306 | 307 | We're copying over the source file we wrote and then ensuring it is 308 | executable. Interestingly, we can rename it to not include a `.hs` 309 | file extension. There is plenty of debate in the world around whether 310 | scripts should or should not include an extension indicating their 311 | source language; Haskell is allowing that debate to perpetuate :). 312 | 313 | ```dockerfile 314 | RUN useradd -m www && mkdir -p /workdir && chown www /workdir 315 | USER www 316 | ``` 317 | 318 | While not strictly necessary, we'd rather not run our executable as 319 | the root user, for security purposes. Let's create a new user, create 320 | a working directory to store files in, and run all subsequent commands 321 | as the new user. 322 | 323 | ```dockerfile 324 | RUN /usr/local/bin/file-server sanity 325 | ``` 326 | 327 | As I mentioned above, that initial run of the server takes a long 328 | time. We'd like to do the heavy lifting of downloading and installing 329 | during the Docker image build rather than at runtime. To make this 330 | happen, we run our program once with the `sanity` command line 331 | argument, so that it immediately exits after successfully starting up. 332 | 333 | ```dockerfile 334 | CMD ["/usr/local/bin/dumb-init", "/usr/local/bin/file-server"] 335 | WORKDIR /workdir 336 | EXPOSE 8080 337 | ``` 338 | 339 | Finally, we use `CMD`, `WORKDIR`, and `EXPOSE` to make it easier to 340 | run. This Docker image is available on Docker Hub, so if you'd like to try 341 | it out without doing a full build on your local machine: 342 | 343 | ```shell 344 | docker run --rm -p 8080:8080 snoyberg/file-server-demo 345 | ``` 346 | 347 | You should be able to play with the application on 348 | [http://localhost:8080](http://localhost:8080). 349 | 350 | ## What's next 351 | 352 | As you can see, getting started with Haskell as a scripting language 353 | is easy. You may be interested in checking out 354 | [the turtle library](https://www.stackage.org/haddock/lts-6.11/turtle-1.2.8/Turtle-Tutorial.html), 355 | which is a Shell scripting DSL written in Haskell. 356 | 357 | If you're ready to get deeper into Haskell, I'd recommend: 358 | 359 | * Check out [haskell-lang.org](https://haskell-lang.org/), which has a 360 | lot of beginner-targeted information, and we're adding more 361 | regularly. 362 | * Check out 363 | [Haskell Programming from First Principles](http://haskellbook.com/), 364 | a book which will get you completely up and running with Haskell 365 | * Join one of the many 366 | [Haskell online communities](https://haskell-lang.org/community) 367 | 368 | FP Complete both supports the open source Haskell ecosystem, as well 369 | as provides commercial support for those seeking it. If you're 370 | interested in learning more about how FP Complete can help you and 371 | your team be more successful in your development and devops work, you 372 | can 373 | [learn about what services we offer](https://www.fpcomplete.com/dev) 374 | or 375 | [contact us for a free consultation](mailto:consulting@fpcomplete.com). 376 | --------------------------------------------------------------------------------