├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── circle.yml ├── demo ├── README.md ├── elm.json ├── serverless.yml ├── src │ ├── Config │ │ ├── API.elm │ │ └── api.js │ ├── Forms │ │ ├── API.elm │ │ └── api.js │ ├── Hello │ │ ├── API.elm │ │ └── api.js │ ├── Interop │ │ ├── API.elm │ │ └── api.js │ ├── Logging.elm │ ├── Pipelines │ │ ├── API.elm │ │ └── api.js │ ├── Quoted │ │ ├── API.elm │ │ ├── Middleware.elm │ │ ├── Models │ │ │ └── Quote.elm │ │ ├── Pipelines │ │ │ └── Quote.elm │ │ ├── Route.elm │ │ ├── Types.elm │ │ └── api.js │ ├── Routing │ │ ├── API.elm │ │ └── api.js │ └── SideEffects │ │ ├── API.elm │ │ └── api.js └── webpack.config.js ├── elm.json ├── es-logo-small.png ├── es-logo.png ├── package.json ├── scripts ├── ci-elm-hack.sh ├── stop-test-server.js └── test-server.js ├── src-bridge ├── index.js ├── logger.js ├── normalize-headers.js ├── pool.js ├── request-handler.js ├── response-handler.js ├── validate.js └── xmlhttprequest.js ├── src ├── Serverless.elm └── Serverless │ ├── Conn.elm │ ├── Conn │ ├── Body.elm │ ├── Charset.elm │ ├── IpAddress.elm │ ├── KeyValueList.elm │ ├── Pool.elm │ ├── Request.elm │ └── Response.elm │ ├── Cors.elm │ └── Plug.elm ├── test ├── bridge │ ├── bridge-spec.js │ ├── normalize-headers-spec.js │ ├── pool-spec.js │ ├── request-handler-spec.js │ ├── response-handler-spec.js │ ├── spy-logger.js │ └── validate-spec.js ├── demo │ ├── config-spec.js │ ├── forms-spec.js │ ├── hello-spec.js │ ├── interop-spec.js │ ├── pipelines-spec.js │ ├── quoted-spec.js │ ├── request.js │ ├── routing-spec.js │ └── side-effects-spec.js └── mocha.opts └── tests ├── Serverless ├── Conn │ ├── DecodeTests.elm │ ├── EncodeTests.elm │ ├── Fuzz.elm │ ├── PoolTests.elm │ └── Test.elm └── ConnTests.elm ├── TestHelpers.elm ├── elm-doc-test.json ├── elm-verify-examples.json └── elm.json.elmupgrade /.editorconfig: -------------------------------------------------------------------------------- 1 | # -*- mode: ini-generic -*- 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | elm-stuff 2 | node_modules 3 | .coverage 4 | .webpack 5 | src-bridge/xmlhttprequest.js 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | extends: airbnb 2 | env: 3 | mocha: true 4 | rules: 5 | arrow-parens: off 6 | comma-dangle: off 7 | func-names: off 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /tests/Doc 2 | elm-stuff 3 | node_modules 4 | *.log 5 | .coverage 6 | .serverless 7 | .webpack 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | elm-stuff 3 | demo 4 | node_modules 5 | scripts 6 | src 7 | test 8 | tests 9 | .* 10 | circle.yml 11 | elm-package.json 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## 4.0.0 - ktonon/elm-serverless 4 | 5 | * `Conn` and `Plug` are now opaque. 6 | * `Plug` has been greatly simplified. 7 | * Simpler pipelines, just `|>` chains of `Conn -> Conn` functions. However pipelines can still send responses and terminate the connection early. 8 | * A single update function (just like an Elm SPA). 9 | * Proper JavaScript interop. 10 | 11 | ## 1.0.0 - the-sett/elm-serverless 12 | 13 | * Upgraded to Elm 0.19 14 | * Removed JavaScript interop - just use ports. Was needed as used Debug.toString to get the function names to call 15 | and this is not allowed in Elm 0.19. 16 | * Removed Logging - can't put Debug.log calls in an Elm 0.19 package. Will re-instate as a logging port in future release. 17 | * Forked as the-sett/elm-serverless. 18 | * Bridge published to npm as serverless-elm-bridge. 19 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at kevin@betweenconcepts.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Running Tests 4 | 5 | `elm-serverless` targets Node.js 6.10. To get a development environment setup, fork and clone this repo. `npm install` will also install elm packages for the base library as well as the demo. `npm test` will perform the full range of tests including: 6 | 7 | * [./test/bridge][]: unit tests for the JavaScript package 8 | * [./test/demo][]: end-to-end tests for the included demo 9 | * [./test/Serverless][]: unit tests for the elm package 10 | 11 | The demo tests are written in JavaScript using [supertest][] and [mocha][] and rely on a running test instance of the demo server, which is started automatically when you run `npm test`. You can also launch tests in watch mode with the command `npm run test:watch`. 12 | 13 | ## Formatting 14 | 15 | This project uses [elm-format](https://github.com/avh4/elm-format/releases/tag/0.7.0-exp) release 0.7.0-exp. 16 | 17 | ```shell 18 | npm install -g elm-format@exp 19 | ``` 20 | 21 | [Editor plugins](https://github.com/avh4/elm-format#editor-integration) are available to apply formatting on each save. 22 | 23 | [./test/bridge]:https://github.com/ktonon/elm-serverless/blob/master/test/bridge 24 | [./test/demo]:https://github.com/ktonon/elm-serverless/blob/master/test/demo 25 | [./test/Serverless]:https://github.com/ktonon/elm-serverless/blob/master/test/Serverless 26 | [mocha]:https://mochajs.org/ 27 | [supertest]:https://github.com/visionmedia/supertest 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Kevin Tonon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 2 | 3 | **Contacts for Support** 4 | - @rupertlssmith on https://elmlang.slack.com 5 | - @rupert on https://discourse.elm-lang.org 6 | 7 | # elm-serverless 8 | 9 | 10 | Deploy an [Elm](https://elm-lang.org) HTTP API to using the [serverless](https://www.serverless.com/) framework. This can be used to write [AWS Lambda](https://aws.amazon.com/lambda/) functions in Elm. Other cloud serverless functions are supported too, through the serverless framework. 11 | 12 | `elm/http` defines an API for making HTTP requests. 13 | 14 | `the-sett/elm-serverless` defines an API for receiving HTTP requests, and responding to them. 15 | 16 | It can be run standalone on your local machine, which is often used for development and testing purposes. It is usually deployed to the cloud using the [serverless](https://www.serverless.com/) framework. 17 | 18 | ## npm package - serverless-elm-bridge 19 | 20 | Define your API in elm and then use the npm package to bridge the interface between the [serverless][] framework and your Elm program. The npm package is 21 | available here: https://www.npmjs.com/package/@the-sett/serverless-elm-bridge 22 | 23 | This can be installed into your `package.json` like this: 24 | 25 | ``` 26 | "dependencies": { 27 | "@the-sett/serverless-elm-bridge": "^3.0.0", 28 | ... 29 | ``` 30 | 31 | The same version of the npm bridge package should be used as the Elm package. 32 | 33 | ## Documentation 34 | 35 | * [Example Code](https://github.com/the-sett/elm-serverless-demo) - Best place 36 | to start learning about the framework. Contains several small programs each 37 | demonstrating a separate feature. Each demo is supported by an end-to-end 38 | suite of tests. 39 | 40 | There are instructions there on how to get set up and deploy an Elm serverless 41 | application on AWS. 42 | 43 | * [API Docs](https://package.elm-lang.org/packages/the-sett/elm-serverless/latest/) - Hosted on elm-lang packages, detailed per module and function documentation. Examples are doc-tested. 44 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.10 4 | 5 | dependencies: 6 | cache_directories: 7 | - ~/sysconfcpus 8 | - node_modules 9 | override: 10 | - npm install 11 | - ./scripts/ci-elm-hack.sh 12 | 13 | test: 14 | post: 15 | - npm install -g coveralls 16 | - cat ./.coverage/lcov.info | coveralls 17 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Demo Programs 2 | 3 | This folder provides an example usage of [elm-serverless][]. 4 | 5 | ## Run locally 6 | 7 | We use [serverless-offline][] to run the server locally during development. To get started, clone this repo and then: 8 | 9 | * `npm install` 10 | * `npm start` 11 | 12 | Which will start a server listening on port `3000`. Note that the demo includes multiple, independent, elm-serverless programs which are deployed as a bundle. Each program contains: 13 | 14 | * `API.elm` - the main entry point of the Elm HTTP API 15 | * `api.js` - a small bridge from JavaScript to Elm 16 | 17 | Learn by reading the demos in the following order: 18 | 19 | | Demo | Path | Description | 20 | | --------------- | ----------------- | ------------------------------------ | 21 | | [Hello][] | [/][] | Bare bones hello world app. | 22 | | [Routing][] | [/routing][] | Parse request path into Elm data. | 23 | | [Forms][] | [/forms][] | Shows how to parse a JSON body. | 24 | | [Config][] | [/config][] | Load per-instance configuration. | 25 | | [Pipelines][] | [/pipelines][] | Build chains of middleware. | 26 | | [SideEffects][] | [/side-effects][] | Handle effects in the update loop. | 27 | | [Interop][] | [/interop][] | Call JavaScript functions. | 28 | | [Quoted][] | [/quoted][] | Shows one way to organize a project. | 29 | 30 | See [serverless.yml][] and [webpack.config.js][] for details on how elm-serverless apps get mapped to base paths. 31 | 32 | ## Deploy to AWS Lambda 33 | 34 | Setup `AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` in your environment. Make sure you have sufficient permissions to perform a serverless deployment (either admin rights, or [something more restricted](https://github.com/serverless/serverless/issues/1439)). Then `npm run deploy:demo`. If all goes well you'll see something like this in the output: 35 | 36 | ```shell 37 | endpoints: 38 | ANY - https://***.execute-api.us-east-1.amazonaws.com/dev/ 39 | ANY - https://***.execute-api.us-east-1.amazonaws.com/dev/{proxy+} 40 | ``` 41 | 42 | Call the first endpoint to test your deployed function. 43 | 44 | ## How it works 45 | 46 | Two tools are involved in getting your elm app on [AWS Lambda][]: 47 | 48 | * [webpack][] along with [elm-webpack-loader][] compiles your elm code to JavaScript 49 | * [serverless][] along with [serverless-webpack][] packages and deploys your app to [AWS Lambda][] 50 | 51 | [/]:http://localhost:3000 52 | [/config]:http://localhost:3000/config 53 | [/forms]:http://localhost:3000/forms 54 | [/interop]:http://localhost:3000/interop 55 | [/pipelines]:http://localhost:3000/pipelines 56 | [/quoted]:http://localhost:3000/quoted 57 | [/quoted/number]:http://localhost:3000/quoted/number 58 | [/quoted/quote]:http://localhost:3000/quoted/quote 59 | [/routing]:http://localhost:3000/routing 60 | [/side-effects]:http://localhost:3000/side-effects 61 | 62 | [Config]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/Config 63 | [Forms]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/Forms 64 | [Hello]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/Hello 65 | [Interop]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/Interop 66 | [Pipelines]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/Pipelines 67 | [Quoted]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/Quoted 68 | [Routing]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/Routing 69 | [SideEffects]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/SideEffects 70 | 71 | [API.elm]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/API.elm 72 | [api.js]:https://github.com/ktonon/elm-serverless/blob/master/demo/src/api.js 73 | [AWS Lambda]:https://aws.amazon.com/lambda 74 | [elm-serverless]:https://github.com/ktonon/elm-serverless 75 | [elm-webpack-loader]:https://github.com/elm-community/elm-webpack-loader 76 | [serverless-offline]:https://github.com/dherault/serverless-offline 77 | [serverless-webpack]:https://github.com/elastic-coders/serverless-webpack 78 | [serverless.yml]:https://github.com/ktonon/elm-serverless/blob/master/demo/serverless.yml 79 | [serverless]:https://serverless.com/ 80 | [webpack.config.js]:https://github.com/ktonon/elm-serverless/blob/master/demo/webpack.config.js 81 | [webpack]:https://webpack.github.io/ 82 | -------------------------------------------------------------------------------- /demo/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "../src" 6 | ], 7 | "elm-version": "0.19.0", 8 | "dependencies": { 9 | "direct": { 10 | "NoRedInk/elm-json-decode-pipeline": "1.0.0", 11 | "elm/core": "1.0.2", 12 | "elm/http": "2.0.0", 13 | "elm/json": "1.1.3", 14 | "elm/random": "1.0.0", 15 | "elm/url": "1.0.0" 16 | }, 17 | "indirect": { 18 | "elm/bytes": "1.0.8", 19 | "elm/file": "1.0.5", 20 | "elm/time": "1.0.0" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": {}, 25 | "indirect": {} 26 | } 27 | } -------------------------------------------------------------------------------- /demo/serverless.yml: -------------------------------------------------------------------------------- 1 | service: elm-serverless-demo 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs8.10 6 | 7 | plugins: 8 | - serverless-webpack 9 | - serverless-offline 10 | 11 | custom: 12 | serverless-offline: 13 | dontPrintOutput: true 14 | 15 | functions: 16 | hello: 17 | handler: src/Hello/api.handler # Refers to function `handler` exported from `Hello/api.js` 18 | events: 19 | - http: 20 | integration: lambda-proxy 21 | path: / 22 | method: ANY 23 | 24 | config: 25 | handler: src/Config/api.handler 26 | events: 27 | - http: 28 | integration: lambda-proxy 29 | path: /config 30 | method: ANY 31 | 32 | forms: 33 | handler: src/Forms/api.handler 34 | events: 35 | - http: 36 | integration: lambda-proxy 37 | path: /forms 38 | method: ANY 39 | 40 | routing: 41 | handler: src/Routing/api.handler 42 | events: 43 | - http: 44 | integration: lambda-proxy 45 | path: /routing 46 | method: ANY 47 | - http: 48 | integration: lambda-proxy 49 | path: /routing/{proxy+} # Lambda Proxy Integration 50 | method: ANY 51 | 52 | pipelines: 53 | handler: src/Pipelines/api.handler 54 | events: 55 | - http: 56 | integration: lambda-proxy 57 | path: /pipelines 58 | method: ANY 59 | 60 | sideEffects: 61 | handler: src/SideEffects/api.handler 62 | events: 63 | - http: 64 | integration: lambda-proxy 65 | path: /side-effects 66 | method: ANY 67 | - http: 68 | integration: lambda-proxy 69 | path: /side-effects/{proxy+} 70 | method: ANY 71 | 72 | interop: 73 | handler: src/Interop/api.handler 74 | events: 75 | - http: 76 | integration: lambda-proxy 77 | path: /interop 78 | method: ANY 79 | - http: 80 | integration: lambda-proxy 81 | path: /interop/{proxy+} 82 | method: ANY 83 | 84 | quoted: 85 | handler: src/Quoted/api.handler 86 | events: 87 | - http: 88 | integration: lambda-proxy 89 | path: /quoted 90 | method: ANY 91 | - http: 92 | integration: lambda-proxy 93 | path: /quoted/{proxy+} 94 | method: ANY 95 | -------------------------------------------------------------------------------- /demo/src/Config/API.elm: -------------------------------------------------------------------------------- 1 | port module Config.API exposing (main) 2 | 3 | import Json.Decode exposing (Decoder, andThen, fail, int, map, string, succeed) 4 | import Json.Decode.Pipeline exposing (required) 5 | import Json.Encode as Encode 6 | import Serverless 7 | import Serverless.Conn exposing (config, respond) 8 | import Serverless.Conn.Body as Body 9 | 10 | 11 | {-| Shows how to load per-instance configuration. 12 | -} 13 | main : Serverless.Program Config () () () 14 | main = 15 | Serverless.httpApi 16 | { initialModel = () 17 | , parseRoute = Serverless.noRoutes 18 | , update = Serverless.noSideEffects 19 | , requestPort = requestPort 20 | , responsePort = responsePort 21 | , interopPorts = Serverless.noPorts 22 | 23 | -- Decodes per instance configuration into Elm data. If decoding fails 24 | -- the server will return 500 for every request and log details about 25 | -- the failure. This decoder is called once at startup. 26 | , configDecoder = configDecoder 27 | 28 | -- Once we get here, we can be sure that the config has been parsed 29 | -- into Elm data, and can be accessed using `Conn.config` 30 | , endpoint = 31 | \conn -> 32 | respond 33 | ( 200 34 | , Body.text <| (++) "Config: " <| configToString (config conn) 35 | ) 36 | conn 37 | } 38 | 39 | 40 | 41 | -- CONFIG TYPES 42 | 43 | 44 | configToString : Config -> String 45 | configToString config = 46 | Encode.encode 0 (configEncoder config) 47 | 48 | 49 | type alias Config = 50 | { auth : Auth 51 | , someService : Service 52 | } 53 | 54 | 55 | type alias Auth = 56 | { secret : String } 57 | 58 | 59 | type alias Service = 60 | { protocol : Protocol 61 | , host : String 62 | , port_ : Int 63 | } 64 | 65 | 66 | type Protocol 67 | = Http 68 | | Https 69 | 70 | 71 | 72 | -- DECODERS 73 | 74 | 75 | configDecoder : Decoder Config 76 | configDecoder = 77 | succeed Config 78 | |> required "auth" (succeed Auth |> required "secret" string) 79 | |> required "someService" serviceDecoder 80 | 81 | 82 | authEncoder : Auth -> Encode.Value 83 | authEncoder auth = 84 | [ ( "secret", Encode.string auth.secret ) ] 85 | |> Encode.object 86 | 87 | 88 | configEncoder : Config -> Encode.Value 89 | configEncoder config = 90 | [ ( "auth", authEncoder config.auth ) 91 | , ( "someService", serviceEncoder config.someService ) 92 | ] 93 | |> Encode.object 94 | 95 | 96 | serviceDecoder : Decoder Service 97 | serviceDecoder = 98 | succeed Service 99 | |> required "protocol" protocolDecoder 100 | |> required "host" string 101 | |> required "port" (string |> andThen (String.toInt >> maybeToDecoder)) 102 | 103 | 104 | serviceEncoder : Service -> Encode.Value 105 | serviceEncoder service = 106 | [ ( "protocol", protocolEncoder service.protocol ) 107 | , ( "host", Encode.string service.host ) 108 | , ( "port", Encode.int service.port_ ) 109 | ] 110 | |> Encode.object 111 | 112 | 113 | protocolDecoder : Decoder Protocol 114 | protocolDecoder = 115 | andThen 116 | (\w -> 117 | case String.toLower w of 118 | "http" -> 119 | succeed Http 120 | 121 | "https" -> 122 | succeed Https 123 | 124 | _ -> 125 | fail "" 126 | ) 127 | string 128 | 129 | 130 | protocolEncoder : Protocol -> Encode.Value 131 | protocolEncoder protocol = 132 | case protocol of 133 | Http -> 134 | Encode.string "http" 135 | 136 | Https -> 137 | Encode.string "https" 138 | 139 | 140 | maybeToDecoder : Maybe a -> Decoder a 141 | maybeToDecoder maybe = 142 | case maybe of 143 | Just val -> 144 | succeed val 145 | 146 | Nothing -> 147 | fail "nothing" 148 | 149 | 150 | port requestPort : Serverless.RequestPort msg 151 | 152 | 153 | port responsePort : Serverless.ResponsePort msg 154 | -------------------------------------------------------------------------------- /demo/src/Config/api.js: -------------------------------------------------------------------------------- 1 | const elmServerless = require('../../../src-bridge'); 2 | 3 | // Webpack has trouble with shebangs (#!) 4 | const rc = require('strip-debug-loader!shebang-loader!rc'); // eslint-disable-line 5 | 6 | const { 7 | Elm 8 | } = require('./API.elm'); 9 | 10 | // Use AWS Lambda environment variables to override these values. 11 | // See the npm rc package README for more details. 12 | // 13 | // Try changing these locally by starting the server with environment variables. 14 | // For example, 15 | // 16 | // demoConfig_someService__protocol=HttPs npm start 17 | // 18 | // Also try forcing the decoder to fail to see diagnostics in the logs, 19 | // 20 | // demoConfig_someService__port=not-a-number npm start 21 | // 22 | // Of course, rc has nothing to do with elm-serverless, you can load 23 | // configuration using another tool if you prefer. 24 | // 25 | const config = rc('demoConfig', { 26 | 27 | auth: { 28 | secret: 'secret' 29 | }, 30 | 31 | someService: { 32 | protocol: 'http', 33 | host: 'localhost', 34 | // Given that these are likely to be configured with environment variables, 35 | // you should only use strings here, and convert them into other values 36 | // using Elm decoders. 37 | port: '3131', 38 | }, 39 | 40 | }); 41 | 42 | const app = Elm.Config.API.init({ 43 | flags: config 44 | }); 45 | 46 | module.exports.handler = elmServerless.httpApi({ 47 | app 48 | }); 49 | -------------------------------------------------------------------------------- /demo/src/Forms/API.elm: -------------------------------------------------------------------------------- 1 | port module Forms.API exposing (main) 2 | 3 | import Json.Decode exposing (Decoder, decodeValue, errorToString, int, map, string, succeed) 4 | import Json.Decode.Pipeline exposing (required) 5 | import Json.Encode as Encode 6 | import Serverless 7 | import Serverless.Conn exposing (method, request, respond) 8 | import Serverless.Conn.Body as Body 9 | import Serverless.Conn.Request exposing (Method(..), body) 10 | 11 | 12 | {-| Shows one way to convert a JSON POST body into Elm data 13 | -} 14 | main : Serverless.Program () () () () 15 | main = 16 | Serverless.httpApi 17 | { configDecoder = Serverless.noConfig 18 | , initialModel = () 19 | , parseRoute = Serverless.noRoutes 20 | , update = Serverless.noSideEffects 21 | , requestPort = requestPort 22 | , responsePort = responsePort 23 | , interopPorts = Serverless.noPorts 24 | 25 | -- Entry point for new connections. 26 | , endpoint = endpoint 27 | } 28 | 29 | 30 | type alias Person = 31 | { name : String 32 | , age : Int 33 | } 34 | 35 | 36 | endpoint : Conn -> ( Conn, Cmd () ) 37 | endpoint conn = 38 | let 39 | decodeResult val = 40 | decodeValue personDecoder val |> Result.mapError errorToString 41 | 42 | result = 43 | conn |> request |> body |> Body.asJson |> Result.andThen decodeResult 44 | in 45 | case ( method conn, result ) of 46 | ( POST, Ok person ) -> 47 | respond ( 200, Body.text <| Encode.encode 0 (personEncoder person) ) conn 48 | 49 | ( POST, Err err ) -> 50 | respond 51 | ( 400 52 | , Body.text <| "Could not decode request body. " ++ err 53 | ) 54 | conn 55 | 56 | _ -> 57 | respond ( 405, Body.text "Method not allowed" ) conn 58 | 59 | 60 | personDecoder : Decoder Person 61 | personDecoder = 62 | succeed Person 63 | |> required "name" string 64 | |> required "age" int 65 | 66 | 67 | personEncoder : Person -> Encode.Value 68 | personEncoder person = 69 | [ ( "name", Encode.string person.name ) 70 | , ( "age", Encode.int person.age ) 71 | ] 72 | |> Encode.object 73 | 74 | 75 | type alias Conn = 76 | Serverless.Conn.Conn () () () () 77 | 78 | 79 | port requestPort : Serverless.RequestPort msg 80 | 81 | 82 | port responsePort : Serverless.ResponsePort msg 83 | -------------------------------------------------------------------------------- /demo/src/Forms/api.js: -------------------------------------------------------------------------------- 1 | const elmServerless = require('../../../src-bridge'); 2 | 3 | const { Elm } = require('./API.elm'); 4 | 5 | module.exports.handler = elmServerless.httpApi({ 6 | app: Elm.Forms.API.init(), 7 | }); 8 | -------------------------------------------------------------------------------- /demo/src/Hello/API.elm: -------------------------------------------------------------------------------- 1 | port module Hello.API exposing (main) 2 | 3 | import Serverless 4 | import Serverless.Conn exposing (respond) 5 | import Serverless.Conn.Body as Body 6 | 7 | 8 | {-| This is the "hello world" of elm-serverless. 9 | 10 | Most functionality has been disabled, by opting-out with the 11 | `Serverless.no...` constructors 12 | 13 | -} 14 | main : Serverless.Program () () () () 15 | main = 16 | Serverless.httpApi 17 | { configDecoder = Serverless.noConfig 18 | , initialModel = () 19 | , parseRoute = Serverless.noRoutes 20 | , update = Serverless.noSideEffects 21 | , interopPorts = Serverless.noPorts 22 | 23 | -- Entry point for new connections. 24 | , endpoint = respond ( 200, Body.text "Hello Elm on AWS Lambda" ) 25 | 26 | -- Provides ports to the framework which are used for requests, 27 | -- and responses. Do not use these ports directly, the framework 28 | -- handles associating messages to specific connections with 29 | -- unique identifiers. 30 | , requestPort = requestPort 31 | , responsePort = responsePort 32 | } 33 | 34 | 35 | port requestPort : Serverless.RequestPort msg 36 | 37 | 38 | port responsePort : Serverless.ResponsePort msg 39 | -------------------------------------------------------------------------------- /demo/src/Hello/api.js: -------------------------------------------------------------------------------- 1 | // You would normally: 2 | // 3 | // npm install -S elm-serverless 4 | // 5 | // and then require it like this: 6 | // 7 | // const elmServerless = require('elm-serverless'); 8 | // 9 | // but this demo is nested in the `elm-serverless` repo, so we just 10 | // require it relative to the current module's location 11 | // 12 | const elmServerless = require('../../../src-bridge'); 13 | 14 | // Import the elm app 15 | const { Elm } = require('./API.elm'); 16 | 17 | // Create an AWS Lambda handler which bridges to the Elm app. 18 | module.exports.handler = elmServerless.httpApi({ 19 | 20 | // Your elm app is the handler 21 | app: Elm.Hello.API.init(), 22 | 23 | // Because elm libraries cannot expose ports, you have to define them. 24 | // Whatever you call them, you have to provide the names. 25 | // The meanings are obvious. A connection comes in through the requestPort, 26 | // and the response is sent back through the responsePort. 27 | // 28 | // These are the default values, so if you follow the convention of naming 29 | // your ports in this way, you can omit these options. 30 | requestPort: 'requestPort', 31 | responsePort: 'responsePort', 32 | }); 33 | -------------------------------------------------------------------------------- /demo/src/Interop/API.elm: -------------------------------------------------------------------------------- 1 | port module Interop.API exposing (Conn, Msg(..), Route(..), endpoint, main, requestPort, responsePort, update) 2 | 3 | import Json.Decode 4 | import Json.Encode 5 | import Serverless 6 | import Serverless.Conn exposing (respond, route) 7 | import Serverless.Conn.Body as Body 8 | import Url.Parser exposing ((), int, map, oneOf, s, top) 9 | 10 | 11 | {-| Shows how to use the update function to handle side-effects. 12 | -} 13 | main : Serverless.Program () () Route Msg 14 | main = 15 | Serverless.httpApi 16 | { configDecoder = Serverless.noConfig 17 | , initialModel = () 18 | , requestPort = requestPort 19 | , responsePort = responsePort 20 | , interopPorts = [ respondRand ] 21 | , parseRoute = 22 | oneOf 23 | [ map Unit (s "unit") 24 | ] 25 | |> Url.Parser.parse 26 | , endpoint = endpoint 27 | , update = update 28 | } 29 | 30 | 31 | 32 | -- ROUTING 33 | 34 | 35 | type Route 36 | = Unit 37 | 38 | 39 | endpoint : Conn -> ( Conn, Cmd Msg ) 40 | endpoint conn = 41 | case route conn of 42 | Unit -> 43 | Serverless.interop requestRand 44 | () 45 | (\val -> 46 | Json.Decode.decodeValue 47 | (Json.Decode.map RandomFloat Json.Decode.float) 48 | val 49 | |> Result.withDefault Error 50 | ) 51 | conn 52 | 53 | 54 | 55 | -- UPDATE 56 | 57 | 58 | type Msg 59 | = RandomFloat Float 60 | | Error 61 | 62 | 63 | update : Msg -> Conn -> ( Conn, Cmd Msg ) 64 | update msg conn = 65 | case msg of 66 | RandomFloat val -> 67 | respond ( 200, Body.json <| Json.Encode.float val ) conn 68 | 69 | Error -> 70 | respond ( 500, Body.text "Error during interop." ) conn 71 | 72 | 73 | 74 | -- TYPES 75 | 76 | 77 | type alias Conn = 78 | Serverless.Conn.Conn () () Route Msg 79 | 80 | 81 | port requestPort : Serverless.RequestPort msg 82 | 83 | 84 | port responsePort : Serverless.ResponsePort msg 85 | 86 | 87 | port requestRand : Serverless.InteropRequestPort () msg 88 | 89 | 90 | port respondRand : Serverless.InteropResponsePort msg 91 | -------------------------------------------------------------------------------- /demo/src/Interop/api.js: -------------------------------------------------------------------------------- 1 | const elmServerless = require('../../../src-bridge'); 2 | 3 | const { 4 | Elm 5 | } = require('./API.elm'); 6 | 7 | const app = Elm.Interop.API.init(); 8 | 9 | // Random numbers through a port. 10 | if (app.ports != null && app.ports.requestRand != null) { 11 | app.ports.requestRand.subscribe(args => { 12 | const connectionId = args[0]; 13 | const interopSeq = args[1]; 14 | app.ports.respondRand.send([connectionId, interopSeq, Math.random()]); 15 | }); 16 | } 17 | 18 | // Create the serverless handler with the ports. 19 | module.exports.handler = elmServerless.httpApi({ 20 | app 21 | }); 22 | -------------------------------------------------------------------------------- /demo/src/Logging.elm: -------------------------------------------------------------------------------- 1 | module Logging exposing (LogLevel(..), Logger, defaultLogger, logLevelToInt, logger, nullLogger) 2 | 3 | {-| Available Log levels 4 | -} 5 | 6 | 7 | type LogLevel 8 | = LogDebug 9 | | LogInfo 10 | | LogWarn 11 | | LogError 12 | 13 | 14 | {-| Used to order log levels 15 | -} 16 | logLevelToInt : LogLevel -> Int 17 | logLevelToInt level = 18 | case level of 19 | LogDebug -> 20 | 0 21 | 22 | LogInfo -> 23 | 1 24 | 25 | LogWarn -> 26 | 2 27 | 28 | LogError -> 29 | 3 30 | 31 | 32 | {-| A logger function 33 | -} 34 | type alias Logger a = 35 | LogLevel -> String -> a -> a 36 | 37 | 38 | {-| A logger that only logs messages at a minimum log level 39 | 40 | logger Info -- will not log Debug level messages 41 | 42 | -} 43 | logger : LogLevel -> Logger a 44 | logger minLevel level label val = 45 | if (minLevel |> logLevelToInt) > (level |> logLevelToInt) then 46 | Debug.log (Debug.toString level ++ ": " ++ label) val 47 | 48 | else 49 | val 50 | 51 | 52 | {-| Log level that is used throughout the internal library code 53 | -} 54 | defaultLogger : Logger a 55 | defaultLogger = 56 | logger LogInfo 57 | 58 | 59 | {-| Disable logging completely. 60 | -} 61 | nullLogger : Logger a 62 | nullLogger level label val = 63 | val 64 | -------------------------------------------------------------------------------- /demo/src/Pipelines/API.elm: -------------------------------------------------------------------------------- 1 | port module Pipelines.API exposing (main) 2 | 3 | import Json.Decode exposing (succeed) 4 | import Json.Decode.Pipeline exposing (required) 5 | import Serverless 6 | import Serverless.Conn exposing (..) 7 | import Serverless.Conn.Body as Body 8 | import Serverless.Conn.Response exposing (addHeader, setBody, setStatus) 9 | import Serverless.Cors 10 | import Serverless.Plug as Plug exposing (Plug, plug) 11 | 12 | 13 | {-| Pipelines demo. 14 | 15 | Pipelines are sequences of functions which transform the connection. They are 16 | ideal for building middleware. 17 | 18 | -} 19 | main : Serverless.Program Config () () () 20 | main = 21 | Serverless.httpApi 22 | { initialModel = () 23 | , parseRoute = Serverless.noRoutes 24 | , update = Serverless.noSideEffects 25 | , requestPort = requestPort 26 | , responsePort = responsePort 27 | , interopPorts = Serverless.noPorts 28 | 29 | -- `Plug.apply` transforms the connection by passing it through each plug 30 | -- in a pipeline. After the pipeline is processed, the conn may already 31 | -- be in a "sent" state, so we use `mapUnsent` to conditionally apply 32 | -- the final responder. 33 | -- 34 | -- Even if we didn't use `mapUnsent`, no harm could be done, as a sent 35 | -- conn is immutable. 36 | , endpoint = 37 | Plug.apply pipeline 38 | >> mapUnsent (respond ( 200, Body.text "Pipeline applied" )) 39 | 40 | -- Some middleware may provide a configuration decoder. 41 | , configDecoder = 42 | succeed Config 43 | |> required "cors" Serverless.Cors.configDecoder 44 | } 45 | 46 | 47 | {-| Stores middleware configuration. 48 | -} 49 | type alias Config = 50 | { cors : Serverless.Cors.Config } 51 | 52 | 53 | pipeline : Plug Config () () () 54 | pipeline = 55 | Plug.pipeline 56 | -- Each plug in a pipeline transforms the connection 57 | |> plug (updateResponse <| addHeader ( "x-from-first-plug", "foo" )) 58 | -- Middleware is often distributed as separate packages 59 | |> plug (Serverless.Cors.fromConfig .cors) 60 | -- Some plugs may send a response 61 | |> plug authMiddleware 62 | -- Plugs following a sent response will be skipped 63 | |> plug (updateResponse <| addHeader ( "x-from-last-plug", "bar" )) 64 | 65 | 66 | {-| Some plugs may choose to send a response early. 67 | 68 | This can be done with the `toSent` function, which will make the conn immutable. 69 | `Plug.apply` will skip the remainder of the plugs during processing if at any 70 | point the conn becomes "sent". 71 | 72 | -} 73 | authMiddleware : Conn Config () () () -> Conn Config () () () 74 | authMiddleware conn = 75 | case header "authorization" conn of 76 | Just _ -> 77 | -- real auth would validate this 78 | conn 79 | 80 | Nothing -> 81 | let 82 | body = 83 | Body.text <| 84 | "Unauthorized: Set an Authorization header using curl " 85 | ++ "or postman (value does not matter)" 86 | in 87 | updateResponse (setBody body >> setStatus 401) conn 88 | -- Converts the conn to "sent", meaning the response can no 89 | -- longer be updated, and plugs downstream will be skipped 90 | |> toSent 91 | 92 | 93 | port requestPort : Serverless.RequestPort msg 94 | 95 | 96 | port responsePort : Serverless.ResponsePort msg 97 | -------------------------------------------------------------------------------- /demo/src/Pipelines/api.js: -------------------------------------------------------------------------------- 1 | const elmServerless = require('../../../src-bridge'); 2 | const rc = require('strip-debug-loader!shebang-loader!rc'); // eslint-disable-line 3 | 4 | const { Elm } = require('./API.elm'); 5 | 6 | // Use AWS Lambda environment variables to override these values 7 | // See the npm rc package README for more details 8 | const config = rc('demoPipelines', { 9 | cors: { 10 | origin: '*', 11 | methods: 'get,post,options', 12 | }, 13 | }); 14 | 15 | module.exports.handler = elmServerless.httpApi({ 16 | app: Elm.Pipelines.API.init({ flags: config }), 17 | }); 18 | -------------------------------------------------------------------------------- /demo/src/Quoted/API.elm: -------------------------------------------------------------------------------- 1 | module Quoted.API exposing (main, pipeline, router, update) 2 | 3 | import Json.Encode as Encode 4 | import Quoted.Middleware 5 | import Quoted.Pipelines.Quote as Quote 6 | import Quoted.Route exposing (Route(..), queryEncoder) 7 | import Quoted.Types exposing (Config, Conn, Msg(..), Plug, configDecoder, requestPort, responsePort) 8 | import Random 9 | import Serverless 10 | import Serverless.Conn exposing (mapUnsent, method, respond, route, updateResponse) 11 | import Serverless.Conn.Body as Body 12 | import Serverless.Conn.Request exposing (Method(..)) 13 | import Serverless.Plug as Plug exposing (plug) 14 | import Url.Parser 15 | 16 | 17 | {-| A Serverless.Program is parameterized by your 5 custom types 18 | 19 | - Config is a server load-time record of deployment specific values 20 | - Model is for whatever you need during the processing of a request 21 | - Route represents the set of routes your app will handle 22 | - Msg is your app message type 23 | 24 | -} 25 | main : Serverless.Program Config () Route Msg 26 | main = 27 | Serverless.httpApi 28 | { initialModel = () 29 | 30 | -- Decodes per instance configuration into Elm data. If decoding fails 31 | -- the server will fail to start. This decoder is called once at 32 | -- startup. 33 | , configDecoder = configDecoder 34 | 35 | -- Parses the request path and query string into Elm data. 36 | -- If parsing fails, a 404 is automatically sent. 37 | , parseRoute = Url.Parser.parse Quoted.Route.route 38 | 39 | -- Entry point for new connections. 40 | -- This function composition passes the conn through a pipeline and then 41 | -- into a router (but only if the conn is not sent by the pipeline). 42 | , endpoint = Plug.apply pipeline >> mapUnsent router 43 | 44 | -- Update function which operates on Conn. 45 | , update = update 46 | 47 | -- Provides ports to the framework which are used for requests, 48 | -- and responses. Do not use these ports directly, the framework 49 | -- handles associating messages to specific connections with 50 | -- unique identifiers. 51 | , requestPort = requestPort 52 | , responsePort = responsePort 53 | , interopPorts = Serverless.noPorts 54 | } 55 | 56 | 57 | {-| Pipelines are chains of functions (plugs) which transform the connection. 58 | 59 | These pipelines can optionally send a response through the connection early, for 60 | example a 401 sent if authorization fails. Use Plug.apply to pass a connection 61 | through a pipeline (see above). Note that Plug.apply will stop processing the 62 | pipeline once the connection is sent. 63 | 64 | -} 65 | pipeline : Plug 66 | pipeline = 67 | Plug.pipeline 68 | |> plug Quoted.Middleware.cors 69 | |> plug Quoted.Middleware.auth 70 | 71 | 72 | {-| Just a big "case of" on the request method and route. 73 | 74 | Remember that route is the request path and query string, already parsed into 75 | nice Elm data, courtesy of the parseRoute function provided above. 76 | 77 | -} 78 | router : Conn -> ( Conn, Cmd Msg ) 79 | router conn = 80 | case 81 | ( method conn 82 | , route conn 83 | ) 84 | of 85 | ( GET, Home query ) -> 86 | respond ( 200, Body.text <| (++) "Home: " <| Encode.encode 0 (queryEncoder query) ) conn 87 | 88 | ( _, Quote lang ) -> 89 | -- Delegate to Pipeline/Quote module. 90 | Quote.router lang conn 91 | 92 | ( GET, Number ) -> 93 | -- Generate a random number. 94 | ( conn 95 | , Random.generate RandomNumber <| Random.int 0 1000000000 96 | ) 97 | 98 | ( GET, Buggy ) -> 99 | respond ( 500, Body.text "bugs, bugs, bugs" ) conn 100 | 101 | _ -> 102 | respond ( 405, Body.text "Method not allowed" ) conn 103 | 104 | 105 | {-| The application update function. 106 | 107 | Just like an Elm SPA, an elm-serverless app has a single update 108 | function which handles messages resulting from side-effects. 109 | 110 | -} 111 | update : Msg -> Conn -> ( Conn, Cmd Msg ) 112 | update msg conn = 113 | case msg of 114 | -- This message is intended for the Pipeline/Quote module 115 | GotQuotes result -> 116 | Quote.gotQuotes result conn 117 | 118 | RandomNumber val -> 119 | respond ( 200, Body.json <| Encode.int val ) conn 120 | -------------------------------------------------------------------------------- /demo/src/Quoted/Middleware.elm: -------------------------------------------------------------------------------- 1 | module Quoted.Middleware exposing (auth, cors) 2 | 3 | {-| Middleware is just a simple function which transforms a connection. 4 | -} 5 | 6 | import Quoted.Types exposing (Conn) 7 | import Serverless.Conn exposing (config, header, request, toSent, updateResponse) 8 | import Serverless.Conn.Body as Body 9 | import Serverless.Conn.Response exposing (addHeader, setBody, setStatus) 10 | 11 | 12 | {-| Simple function to add some cors response headers 13 | -} 14 | cors : 15 | Serverless.Conn.Conn config model route msg 16 | -> Serverless.Conn.Conn config model route msg 17 | cors conn = 18 | updateResponse 19 | (addHeader ( "access-control-allow-origin", "*" ) 20 | >> addHeader ( "access-control-allow-methods", "GET,POST" ) 21 | -- ... 22 | ) 23 | conn 24 | 25 | 26 | {-| Dumb auth just checks if auth header is present. 27 | 28 | To demonstrate middleware which sends a response. 29 | 30 | -} 31 | auth : Conn -> Conn 32 | auth conn = 33 | case 34 | ( config conn |> .enableAuth 35 | , header "authorization" conn 36 | ) 37 | of 38 | ( True, Nothing ) -> 39 | conn 40 | |> updateResponse 41 | (setStatus 401 42 | >> setBody (Body.text "Authorization header not provided") 43 | ) 44 | |> toSent 45 | 46 | _ -> 47 | conn 48 | -------------------------------------------------------------------------------- /demo/src/Quoted/Models/Quote.elm: -------------------------------------------------------------------------------- 1 | module Quoted.Models.Quote exposing (decodeQuote, encodeList, format, request) 2 | 3 | import Http 4 | import Json.Decode exposing (Decoder, string, succeed) 5 | import Json.Decode.Pipeline exposing (hardcoded, required) 6 | import Json.Encode as Encode exposing (Value) 7 | import Quoted.Types exposing (Quote) 8 | import Task 9 | 10 | 11 | 12 | -- MODEL 13 | 14 | 15 | format : String -> Quote -> String 16 | format lineBreak quote = 17 | quote.text ++ lineBreak ++ "--" ++ quote.author 18 | 19 | 20 | encodeList : List Quote -> Value 21 | encodeList quotes = 22 | Encode.object 23 | [ ( "quotes" 24 | , quotes 25 | |> List.map 26 | (\quote -> 27 | Encode.object 28 | [ ( "lang", quote.lang |> Encode.string ) 29 | , ( "text", quote.text |> Encode.string ) 30 | , ( "author", quote.author |> Encode.string ) 31 | ] 32 | ) 33 | |> Encode.list identity 34 | ) 35 | ] 36 | 37 | 38 | 39 | -- DECODER 40 | 41 | 42 | decodeQuote : String -> Decoder Quote 43 | decodeQuote lang = 44 | succeed Quote 45 | |> hardcoded lang 46 | |> required "quoteText" string 47 | |> required "quoteAuthor" string 48 | 49 | 50 | request : String -> Task.Task Http.Error Quote 51 | request lang = 52 | Http.task 53 | { method = "GET" 54 | , headers = [] 55 | , url = "http://api.forismatic.com/api/1.0/?method=getQuote&format=json&lang=" ++ lang 56 | , body = Http.emptyBody 57 | , resolver = jsonResolver (decodeQuote lang) 58 | , timeout = Nothing 59 | } 60 | 61 | 62 | jsonResolver : Decoder a -> Http.Resolver Http.Error a 63 | jsonResolver decoder = 64 | let 65 | resolveToJson : Http.Response String -> Result Http.Error a 66 | resolveToJson resp = 67 | case resp of 68 | Http.BadUrl_ url -> 69 | Err (Http.BadUrl url) 70 | 71 | Http.Timeout_ -> 72 | Err Http.Timeout 73 | 74 | Http.NetworkError_ -> 75 | Err Http.NetworkError 76 | 77 | Http.BadStatus_ metadata body -> 78 | Err (Http.BadStatus metadata.statusCode) 79 | 80 | Http.GoodStatus_ metadata body -> 81 | case Json.Decode.decodeString decoder body of 82 | Ok value -> 83 | Ok value 84 | 85 | Err err -> 86 | Err (Http.BadBody (Json.Decode.errorToString err)) 87 | in 88 | Http.stringResolver resolveToJson 89 | -------------------------------------------------------------------------------- /demo/src/Quoted/Pipelines/Quote.elm: -------------------------------------------------------------------------------- 1 | module Quoted.Pipelines.Quote exposing (gotQuotes, langFilter, loadQuotes, router) 2 | 3 | import Http 4 | import Quoted.Models.Quote as Quote 5 | import Quoted.Route exposing (..) 6 | import Quoted.Types exposing (Conn, Msg(..), responsePort) 7 | import Serverless.Conn as Conn exposing (method, respond, updateResponse) 8 | import Serverless.Conn.Body as Body 9 | import Serverless.Conn.Request as Request exposing (Method(..)) 10 | import Task 11 | 12 | 13 | router : Lang -> Conn -> ( Conn, Cmd Msg ) 14 | router lang conn = 15 | case method conn of 16 | GET -> 17 | loadQuotes lang conn 18 | 19 | POST -> 20 | respond 21 | ( 501 22 | , Body.text <| 23 | "Not implemented, but I got this body: " 24 | -- ++ (conn |> Conn.request |> Request.body |> toString) 25 | ) 26 | conn 27 | 28 | _ -> 29 | respond ( 405, Body.text "Method not allowed" ) conn 30 | 31 | 32 | loadQuotes : Quoted.Route.Lang -> Conn -> ( Conn, Cmd Msg ) 33 | loadQuotes lang conn = 34 | case 35 | conn 36 | |> Conn.config 37 | |> .languages 38 | |> langFilter lang 39 | of 40 | [] -> 41 | respond ( 404, Body.text "Could not find language" ) conn 42 | 43 | langs -> 44 | ( conn 45 | , -- A response does not need to be sent immediately. 46 | -- Here we make a request to another service... 47 | langs 48 | |> List.map Quote.request 49 | |> Task.sequence 50 | |> Task.attempt GotQuotes 51 | ) 52 | 53 | 54 | gotQuotes : Result Http.Error (List Quoted.Types.Quote) -> Conn -> ( Conn, Cmd Msg ) 55 | gotQuotes result conn = 56 | case result of 57 | Ok q -> 58 | -- ...and send our response once we have the results 59 | respond 60 | ( 200 61 | , q 62 | |> List.sortBy .lang 63 | |> Quote.encodeList 64 | |> Body.json 65 | ) 66 | conn 67 | 68 | Err err -> 69 | --respond ( 500, Body.text <| toString err ) conn 70 | respond ( 500, Body.text <| "HTTP Error" ) conn 71 | 72 | 73 | 74 | -- HELPERS 75 | 76 | 77 | langFilter : Quoted.Route.Lang -> List String -> List String 78 | langFilter filt langs = 79 | case filt of 80 | LangAll -> 81 | langs 82 | 83 | Lang string -> 84 | if langs |> List.member string then 85 | [ string ] 86 | 87 | else 88 | [] 89 | -------------------------------------------------------------------------------- /demo/src/Quoted/Route.elm: -------------------------------------------------------------------------------- 1 | module Quoted.Route exposing (Lang(..), Query, Route(..), Sort(..), lang, query, queryEncoder, route, sort) 2 | 3 | import Json.Encode as Encode 4 | import Url.Parser exposing ((), (), Parser, map, oneOf, s, string, top) 5 | import Url.Parser.Query as Query 6 | 7 | 8 | type Route 9 | = Home Query 10 | | Quote Lang 11 | | Buggy 12 | | Number 13 | 14 | 15 | type Lang 16 | = LangAll 17 | | Lang String 18 | 19 | 20 | type Sort 21 | = Asc 22 | | Desc 23 | 24 | 25 | type alias Query = 26 | { q : String 27 | , sort : Sort 28 | } 29 | 30 | 31 | route : Parser (Route -> a) a 32 | route = 33 | oneOf 34 | [ map Home (top query) 35 | , map Quote (s "quote" lang) 36 | , map Buggy (s "buggy") 37 | , map Number (s "number") 38 | ] 39 | 40 | 41 | lang : Parser (Lang -> a) a 42 | lang = 43 | oneOf 44 | [ map LangAll top 45 | , map Lang string 46 | ] 47 | 48 | 49 | query : Parser (Query -> a) a 50 | query = 51 | map Query 52 | (top 53 | (Query.string "q" |> Query.map (Maybe.withDefault "")) 54 | (Query.string "sort" |> Query.map sort) 55 | ) 56 | 57 | 58 | queryEncoder : Query -> Encode.Value 59 | queryEncoder qry = 60 | [ ( "q", Encode.string qry.q ) 61 | , ( "sort", sortEncoder qry.sort ) 62 | ] 63 | |> Encode.object 64 | 65 | 66 | sortEncoder : Sort -> Encode.Value 67 | sortEncoder srt = 68 | case srt of 69 | Asc -> 70 | Encode.string "Asc" 71 | 72 | Desc -> 73 | Encode.string "Desc" 74 | 75 | 76 | sort : Maybe String -> Sort 77 | sort = 78 | Maybe.withDefault "" 79 | >> (\val -> 80 | if val == "asc" then 81 | Asc 82 | 83 | else 84 | Desc 85 | ) 86 | -------------------------------------------------------------------------------- /demo/src/Quoted/Types.elm: -------------------------------------------------------------------------------- 1 | port module Quoted.Types exposing (Config, Conn, Msg(..), Plug, Quote, configDecoder, requestPort, responsePort) 2 | 3 | import Http 4 | import Json.Decode as Decode exposing (Decoder, succeed) 5 | import Json.Decode.Pipeline exposing (hardcoded, required) 6 | import Json.Encode as Encode 7 | import Quoted.Route exposing (Route) 8 | import Serverless 9 | import Serverless.Conn exposing (Id) 10 | import Serverless.Plug 11 | 12 | 13 | 14 | -- CUSTOM TYPES 15 | -- 16 | -- The following (Config, Model, and Msg) are required by Serverless.Program, 17 | -- but can be defined as anything you want. 18 | 19 | 20 | {-| Can be anything you want, you just need to provide a decoder 21 | -} 22 | type alias Config = 23 | { languages : List String 24 | , enableAuth : Bool 25 | } 26 | 27 | 28 | configDecoder : Decoder Config 29 | configDecoder = 30 | succeed Config 31 | |> required "languages" (Decode.list Decode.string) 32 | |> required "enableAuth" (Decode.string |> Decode.map ((==) "true")) 33 | 34 | 35 | type alias Quote = 36 | { lang : String 37 | , text : String 38 | , author : String 39 | } 40 | 41 | 42 | {-| Your custom message type. 43 | 44 | The only restriction is that it has to contain an endpoint. You can call the 45 | endpoint whatever you want, but it accepts no parameters, and must be provided 46 | to the program as `endpoint` (see above). 47 | 48 | -} 49 | type Msg 50 | = GotQuotes (Result Http.Error (List Quote)) 51 | | RandomNumber Int 52 | 53 | 54 | 55 | -- SERVERLESS TYPES 56 | -- 57 | -- Provide concrete values for the type variable defined in Serverless.Types 58 | -- then import this module instead, to make your code more readable. 59 | 60 | 61 | type alias Conn = 62 | Serverless.Conn.Conn Config () Route Msg 63 | 64 | 65 | type alias Plug = 66 | Serverless.Plug.Plug Config () Route Msg 67 | 68 | 69 | port requestPort : Serverless.RequestPort msg 70 | 71 | 72 | port responsePort : Serverless.ResponsePort msg 73 | -------------------------------------------------------------------------------- /demo/src/Quoted/api.js: -------------------------------------------------------------------------------- 1 | const elmServerless = require('../../../src-bridge'); 2 | const rc = require('strip-debug-loader!shebang-loader!rc'); // eslint-disable-line 3 | 4 | const { Elm } = require('./API.elm'); 5 | 6 | // Use AWS Lambda environment variables to override these values 7 | // See the npm rc package README for more details 8 | const config = rc('demo', { 9 | languages: ['en', 'ru'], 10 | 11 | enableAuth: 'false', 12 | 13 | cors: { 14 | origin: '*', 15 | methods: 'get,post,options', 16 | }, 17 | }); 18 | 19 | module.exports.handler = elmServerless.httpApi({ 20 | app: Elm.Quoted.API.init({ flags: config }), 21 | requestPort: 'requestPort', 22 | responsePort: 'responsePort', 23 | }); 24 | -------------------------------------------------------------------------------- /demo/src/Routing/API.elm: -------------------------------------------------------------------------------- 1 | port module Routing.API exposing (Conn, Route(..), main, requestPort, responsePort, router) 2 | 3 | import Serverless 4 | import Serverless.Conn exposing (method, respond, route) 5 | import Serverless.Conn.Body as Body 6 | import Serverless.Conn.Request exposing (Method(..)) 7 | import Url 8 | import Url.Parser exposing ((), map, oneOf, s, string, top) 9 | 10 | 11 | {-| This is the route parser demo. 12 | 13 | We use a routing function as the endpoint, and provide a route parsing function. 14 | 15 | -} 16 | main : Serverless.Program () () Route () 17 | main = 18 | Serverless.httpApi 19 | { configDecoder = Serverless.noConfig 20 | , initialModel = () 21 | , update = Serverless.noSideEffects 22 | , requestPort = requestPort 23 | , responsePort = responsePort 24 | , interopPorts = Serverless.noPorts 25 | 26 | -- Parses the request path and query string into Elm data. 27 | -- If parsing fails, a 404 is automatically sent. 28 | , parseRoute = 29 | oneOf 30 | [ map Home top 31 | , map BlogList (s "blog") 32 | , map Blog (s "blog" string) 33 | ] 34 | |> Url.Parser.parse 35 | 36 | -- Entry point for new connections. 37 | , endpoint = router 38 | } 39 | 40 | 41 | {-| Routes are represented using an Elm type. 42 | -} 43 | type Route 44 | = Home 45 | | BlogList 46 | | Blog String 47 | 48 | 49 | {-| Perhaps the String -> Url bit should be part of the elm-serverless framework? 50 | -} 51 | routeParser url = 52 | Url.fromString url 53 | |> Maybe.andThen 54 | (Url.Parser.parse 55 | (oneOf 56 | [ map Home top 57 | , map BlogList (s "blog") 58 | , map Blog (s "blog" string) 59 | ] 60 | ) 61 | ) 62 | 63 | 64 | {-| Just a big "case of" on the request method and route. 65 | 66 | Remember that route is the request path and query string, already parsed into 67 | nice Elm data, courtesy of the parseRoute function provided above. 68 | 69 | -} 70 | router : Conn -> ( Conn, Cmd () ) 71 | router conn = 72 | case ( method conn, route conn ) of 73 | ( GET, Home ) -> 74 | respond ( 200, Body.text "The home page" ) conn 75 | 76 | ( GET, BlogList ) -> 77 | respond ( 200, Body.text "List of recent posts..." ) conn 78 | 79 | ( GET, Blog slug ) -> 80 | respond ( 200, Body.text <| (++) "Specific post: " slug ) conn 81 | 82 | _ -> 83 | respond ( 405, Body.text "Method not allowed" ) conn 84 | 85 | 86 | {-| For convenience we defined our own Conn with arguments to the type parameters 87 | -} 88 | type alias Conn = 89 | Serverless.Conn.Conn () () Route () 90 | 91 | 92 | port requestPort : Serverless.RequestPort msg 93 | 94 | 95 | port responsePort : Serverless.ResponsePort msg 96 | -------------------------------------------------------------------------------- /demo/src/Routing/api.js: -------------------------------------------------------------------------------- 1 | const elmServerless = require('../../../src-bridge'); 2 | 3 | const { Elm } = require('./API.elm'); 4 | 5 | module.exports.handler = elmServerless.httpApi({ 6 | app: Elm.Routing.API.init() 7 | }); 8 | -------------------------------------------------------------------------------- /demo/src/SideEffects/API.elm: -------------------------------------------------------------------------------- 1 | port module SideEffects.API exposing (Conn, Msg(..), Route(..), endpoint, main, requestPort, responsePort, update) 2 | 3 | import Json.Encode 4 | import Random 5 | import Serverless 6 | import Serverless.Conn exposing (respond, route) 7 | import Serverless.Conn.Body as Body 8 | import Url.Parser exposing ((), int, map, oneOf, s, top) 9 | 10 | 11 | {-| Shows how to use the update function to handle side-effects. 12 | -} 13 | main : Serverless.Program () () Route Msg 14 | main = 15 | Serverless.httpApi 16 | { configDecoder = Serverless.noConfig 17 | , initialModel = () 18 | , requestPort = requestPort 19 | , responsePort = responsePort 20 | , interopPorts = Serverless.noPorts 21 | 22 | -- Route /:lowerBound/:upperBound 23 | , parseRoute = 24 | oneOf 25 | [ map NumberRange (int int) 26 | , map (NumberRange 0) int 27 | , map (NumberRange 0 1000000000) top 28 | , map Unit (s "unit") 29 | ] 30 | |> Url.Parser.parse 31 | 32 | -- Incoming connection handler 33 | , endpoint = endpoint 34 | 35 | -- Like a SPA update function, but operates on Conn 36 | , update = update 37 | } 38 | 39 | 40 | 41 | -- ROUTING 42 | 43 | 44 | type Route 45 | = NumberRange Int Int 46 | | Unit 47 | 48 | 49 | endpoint : Conn -> ( Conn, Cmd Msg ) 50 | endpoint conn = 51 | case route conn of 52 | NumberRange lower upper -> 53 | ( -- Leave connection unmodified 54 | conn 55 | , -- Issues a command. The result will come into the update 56 | -- function as the RandomNumber message 57 | Random.generate RandomNumber <| 58 | Random.int lower upper 59 | ) 60 | 61 | Unit -> 62 | ( conn 63 | , Random.generate RandomFloat <| 64 | Random.float 0 1 65 | ) 66 | 67 | 68 | 69 | -- UPDATE 70 | 71 | 72 | type Msg 73 | = RandomNumber Int 74 | | RandomFloat Float 75 | 76 | 77 | update : Msg -> Conn -> ( Conn, Cmd Msg ) 78 | update msg conn = 79 | case msg of 80 | RandomNumber val -> 81 | respond ( 200, Body.json <| Json.Encode.int val ) conn 82 | 83 | RandomFloat val -> 84 | respond ( 200, Body.json <| Json.Encode.float val ) conn 85 | 86 | 87 | 88 | -- TYPES 89 | 90 | 91 | type alias Conn = 92 | Serverless.Conn.Conn () () Route Msg 93 | 94 | 95 | port requestPort : Serverless.RequestPort msg 96 | 97 | 98 | port responsePort : Serverless.ResponsePort msg 99 | -------------------------------------------------------------------------------- /demo/src/SideEffects/api.js: -------------------------------------------------------------------------------- 1 | const elmServerless = require('../../../src-bridge'); 2 | 3 | const { Elm } = require('./API.elm'); 4 | 5 | module.exports.handler = elmServerless.httpApi({ 6 | app: Elm.SideEffects.API.init() 7 | }); 8 | -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const slsw = require('serverless-webpack'); 4 | 5 | const config = { 6 | entry: slsw.lib.entries, 7 | target: 'node', // Ignores built-in modules like path, fs, etc. 8 | 9 | output: { 10 | libraryTarget: 'commonjs', 11 | path: path.resolve(`${__dirname}/.webpack`), 12 | filename: '[name].js', 13 | }, 14 | 15 | module: { 16 | loaders: [{ 17 | // Compiles elm to JavaScript. 18 | test: /\.elm$/, 19 | exclude: [/elm-stuff/, /node_modules/], 20 | loader: 'elm-webpack-loader', 21 | options: { 22 | forceWatch: true 23 | } 24 | }], 25 | }, 26 | }; 27 | 28 | if (process.env.NODE_ENV === 'production') { 29 | // Bridge is written for node 6.10. While AWS Lambda supports it 30 | // the UglifyJsPlugin does not :( so until that happens we use babel. 31 | config.module.loaders.push({ 32 | test: /\.js$/, 33 | exclude: [/elm-stuff/, /node_modules/], 34 | loader: 'babel-loader', 35 | options: { presets: 'env' }, 36 | }); 37 | 38 | config.plugins = config.plugins || []; 39 | config.plugins.push(new webpack.optimize.UglifyJsPlugin()); 40 | } 41 | 42 | module.exports = config; 43 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "the-sett/elm-serverless", 4 | "summary": "Use Elm with the serverless framework (deploy to AWS, Azure, Google)", 5 | "license": "MIT", 6 | "version": "4.0.0", 7 | "exposed-modules": [ 8 | "Serverless", 9 | "Serverless.Conn", 10 | "Serverless.Conn.Body", 11 | "Serverless.Conn.Request", 12 | "Serverless.Conn.Response", 13 | "Serverless.Plug", 14 | "Serverless.Cors" 15 | ], 16 | "elm-version": "0.19.0 <= v < 0.20.0", 17 | "dependencies": { 18 | "NoRedInk/elm-json-decode-pipeline": "1.0.0 <= v < 2.0.0", 19 | "elm/core": "1.0.2 <= v < 2.0.0", 20 | "elm/json": "1.1.3 <= v < 2.0.0", 21 | "elm/random": "1.0.0 <= v < 2.0.0", 22 | "elm/url": "1.0.0 <= v < 2.0.0" 23 | }, 24 | "test-dependencies": { 25 | "elm/regex": "1.0.0 <= v < 2.0.0", 26 | "elm-explorations/test": "1.2.2 <= v < 2.0.0", 27 | "ktonon/elm-test-extra": "2.0.1 <= v < 3.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /es-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-sett/elm-serverless/09b5093d51fd86dc28e5e7cf94ed06bec42e247d/es-logo-small.png -------------------------------------------------------------------------------- /es-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-sett/elm-serverless/09b5093d51fd86dc28e5e7cf94ed06bec42e247d/es-logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@the-sett/serverless-elm-bridge", 3 | "version": "4.0.0", 4 | "engines": { 5 | "node": ">=6.10.0" 6 | }, 7 | "description": "Use Elm with the serverless framework (deploy to AWS, Azure, Google)", 8 | "author": "Kevin Tonon ", 9 | "license": "MIT", 10 | "repository": "git@github.com:the-sett/elm-serverless.git", 11 | "main": "src-bridge/index.js", 12 | "scripts": { 13 | "clobber": "rm -rf elm-stuff demo/elm-stuff demo/.webpack tests/elm-stuff tests/Doc", 14 | "rebuild": "npm run clobber && npm install && npm test", 15 | "deploy:demo": "cd demo && cross-env NODE_ENV=production serverless deploy", 16 | "posttest": "node scripts/stop-test-server", 17 | "pretest": "node scripts/test-server", 18 | "start": "cd demo && serverless offline", 19 | "test": "npm run test:js && npm run test:elm && eslint . || true", 20 | "test:js": "istanbul cover --root src-bridge --include-all-sources --dir ./.coverage node_modules/mocha/bin/_mocha -- test/demo test/bridge", 21 | "test:elm": "elm-verify-examples && elm-test", 22 | "test:watch": "elm test --watch & node scripts/test-server && mocha --reporter=dot --watch test/bridge test/demo" 23 | }, 24 | "dependencies": { 25 | "urlencode": "^1.1.0", 26 | "uuid": "^3.1.0" 27 | }, 28 | "devDependencies": { 29 | "babel-core": "^6.25.0", 30 | "babel-loader": "^7.1.1", 31 | "babel-preset-env": "^1.6.0", 32 | "co": "^4.6.0", 33 | "cross-env": "^3.1.4", 34 | "elm": "0.19.0", 35 | "elm-verify-examples": "^3.1.0", 36 | "elm-test": "^0.19.0-rev6", 37 | "elm-webpack-loader": "^5.0.0", 38 | "eslint": "^3.19.0", 39 | "eslint-config-airbnb": "^15.0.2", 40 | "eslint-plugin-import": "^2.7.0", 41 | "eslint-plugin-jsx-a11y": "^5.1.1", 42 | "eslint-plugin-react": "^7.1.0", 43 | "istanbul": "^0.4.5", 44 | "mocha": "^3.4.2", 45 | "ps-list": "^4.0.0", 46 | "rc": "^1.1.6", 47 | "serverless": "^1.19.0", 48 | "serverless-offline": "^3.15.3", 49 | "serverless-webpack": "^4.1.0", 50 | "shebang-loader": "0.0.1", 51 | "should": "^11.2.1", 52 | "sinon": "^2.3.8", 53 | "strip-debug-loader": "^1.0.0", 54 | "superagent-defaults": "^0.1.14", 55 | "supertest": "^3.0.0", 56 | "webpack": "^3.8.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /scripts/ci-elm-hack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SYSCONFCPUS="$HOME/sysconfcpus" 6 | ELM_MAKE="$HOME/$CIRCLE_PROJECT_REPONAME/node_modules/.bin/elm-make" 7 | 8 | # Check if we have already patched it 9 | if [ -f "${ELM_MAKE}-old" ]; 10 | then 11 | echo "Skipping ci-elm-hack" 12 | exit 0 13 | fi 14 | 15 | # Workaround for extremely slow elm compilation 16 | # see https://github.com/elm-lang/elm-compiler/issues/1473#issuecomment-245704142 17 | if [ ! -d "$SYSCONFCPUS/bin" ]; 18 | then 19 | git clone https://github.com/obmarg/libsysconfcpus.git "$SYSCONFCPUS" 20 | pushd "$SYSCONFCPUS" 21 | ./configure --prefix="$SYSCONFCPUS" 22 | make && make install 23 | popd 24 | fi 25 | 26 | mv "$ELM_MAKE" "$ELM_MAKE-old" 27 | printf '%s\n\n' \ 28 | '#!/bin/bash' \ 29 | 'echo "Running elm-make with sysconfcpus -n 2"' \ 30 | "$SYSCONFCPUS"'/bin/sysconfcpus -n 2 '"$ELM_MAKE"'-old "$@"' \ 31 | > "$ELM_MAKE" 32 | chmod +x "$ELM_MAKE" 33 | -------------------------------------------------------------------------------- /scripts/stop-test-server.js: -------------------------------------------------------------------------------- 1 | const psList = require('ps-list'); // eslint-disable-line import/no-extraneous-dependencies 2 | const { port } = require('../test/demo/request'); 3 | 4 | const args = `offline --port=${port}`.split(' '); 5 | const logger = console; 6 | 7 | const findServer = () => psList().then(data => { 8 | const argsPattern = new RegExp(args.join(' ')); 9 | return data.filter(({ name, cmd }) => 10 | name === 'node' && 11 | argsPattern.test(cmd))[0]; 12 | }); 13 | 14 | findServer().then(server => { 15 | if (server) { 16 | logger.info(`Stopping old test server (${server.pid})`); 17 | process.kill(server.pid); 18 | } 19 | }).catch(err => { 20 | logger.error(err); 21 | process.exit(1); 22 | }); 23 | -------------------------------------------------------------------------------- /scripts/test-server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const psList = require('ps-list'); // eslint-disable-line import/no-extraneous-dependencies 3 | const { spawn } = require('child_process'); 4 | const { port } = require('../test/demo/request'); 5 | 6 | const args = `offline --port=${port}`.split(' '); 7 | const logFile = `${__dirname}/test-server.log`; 8 | const logger = console; 9 | 10 | const findServer = () => psList().then(data => { 11 | const argsPattern = new RegExp(args.join(' ')); 12 | return data.filter(({ name, cmd }) => 13 | name === 'node' && 14 | argsPattern.test(cmd))[0]; 15 | }); 16 | 17 | const startServer = () => new Promise((resolve, reject) => { 18 | const out = fs.openSync(logFile, 'w+'); 19 | const server = spawn(`${__dirname}/../node_modules/.bin/serverless`, args, { 20 | cwd: `${__dirname}/../demo`, 21 | detached: true, 22 | env: Object.assign({ 23 | demo_enableAuth: 'true', 24 | }, process.env), 25 | stdio: ['ignore', out, out], 26 | }); 27 | server.unref(); 28 | 29 | let seenBytes = 0; 30 | const readNext = () => { 31 | const stat = fs.fstatSync(out); 32 | const newBytes = stat.size - seenBytes; 33 | if (newBytes > 0) { 34 | const data = Buffer.alloc(newBytes); 35 | fs.readSync(out, data, 0, newBytes, seenBytes); 36 | const line = data.toString('utf8'); 37 | seenBytes = stat.size; 38 | 39 | if (/error/i.test(line)) { 40 | reject(`test server: ${line}`); 41 | return; 42 | } else if (/Serverless: Offline listening on/.test(line)) { 43 | resolve(server.pid); 44 | return; 45 | } 46 | } 47 | setTimeout(readNext, 200); 48 | }; 49 | readNext(); 50 | 51 | server.on('close', code => { 52 | reject(`test server terminated with code: ${code}`); 53 | }); 54 | }).then(pid => { 55 | logger.info(`Test server started (${pid})`); 56 | return true; 57 | }).catch(err => { 58 | logger.error(err); 59 | process.exit(1); 60 | }); 61 | 62 | findServer().then(server => { 63 | if (server) { 64 | logger.info(`Stopping old test server (${server.pid})`); 65 | process.kill(server.pid); 66 | } 67 | logger.info('Starting new test server'); 68 | setTimeout(startServer, 500); 69 | }).catch(err => { 70 | logger.error(err); 71 | process.exit(1); 72 | }); 73 | -------------------------------------------------------------------------------- /src-bridge/index.js: -------------------------------------------------------------------------------- 1 | const xmlhttprequest = require('./xmlhttprequest'); 2 | 3 | const defaultLogger = require('./logger'); 4 | const Pool = require('./pool'); 5 | const requestHandler = require('./request-handler'); 6 | const responseHandler = require('./response-handler'); 7 | const validate = require('./validate'); 8 | 9 | global.XMLHttpRequest = xmlhttprequest.XMLHttpRequest; 10 | 11 | const invalidElmApp = msg => { 12 | throw new Error(`handler.init did not return valid Elm app.${msg}`); 13 | }; 14 | 15 | const httpApi = ({ 16 | app, 17 | logger = defaultLogger, 18 | requestPort = 'requestPort', 19 | responsePort = 'responsePort', 20 | } = {}) => { 21 | if (typeof app !== 'object') { 22 | invalidElmApp(`Got: ${validate.inspect(app)}`); 23 | } 24 | const portNames = `[${Object.keys(app.ports).sort().join(', ')}]`; 25 | 26 | validate(app.ports[responsePort], 'subscribe', { 27 | missing: `No response port named ${responsePort} among: ${portNames}`, 28 | invalid: 'Invalid response port', 29 | }); 30 | 31 | validate(app.ports[requestPort], 'send', { 32 | missing: `No request port named ${requestPort} among: ${portNames}`, 33 | invalid: 'Invalid request port', 34 | }); 35 | 36 | const pool = new Pool({ logger }); 37 | const handleResponse = responseHandler({ pool, logger }); 38 | 39 | app.ports[responsePort].subscribe(([id, jsonValue]) => { 40 | handleResponse(id, jsonValue); 41 | }); 42 | 43 | return requestHandler({ pool, requestPort: app.ports[requestPort] }); 44 | }; 45 | 46 | module.exports = { httpApi }; 47 | -------------------------------------------------------------------------------- /src-bridge/logger.js: -------------------------------------------------------------------------------- 1 | module.exports = console; 2 | -------------------------------------------------------------------------------- /src-bridge/normalize-headers.js: -------------------------------------------------------------------------------- 1 | const norm = oldHeaders => { 2 | const headers = {}; 3 | Object.keys(oldHeaders).forEach(key => { 4 | const val = oldHeaders[key]; 5 | if (val !== undefined && val !== null) { 6 | headers[key.toLowerCase()] = `${val}`; 7 | } 8 | }); 9 | return headers; 10 | }; 11 | 12 | module.exports = norm; 13 | -------------------------------------------------------------------------------- /src-bridge/pool.js: -------------------------------------------------------------------------------- 1 | const defaultLogger = require('./logger'); 2 | 3 | class Pool { 4 | constructor({ logger = defaultLogger } = {}) { 5 | this.connections = {}; 6 | this.logger = logger; 7 | } 8 | 9 | take(id) { 10 | const conn = this.connections[id]; 11 | if (conn === undefined) { 12 | this.logger.error(`No callback for ID: ${id}`); 13 | } else { 14 | delete this.connections[id]; 15 | } 16 | return conn || {}; 17 | } 18 | 19 | put(id, req, callback) { 20 | if (this.connections[id] !== undefined) { 21 | this.logger.error(`Duplicate connection ID: ${id}`); 22 | } 23 | if (typeof callback !== 'function') { 24 | throw new Error(`Callback is not a function: ${callback}`); 25 | } 26 | this.connections[id] = { req, callback, id }; 27 | } 28 | } 29 | 30 | module.exports = Pool; 31 | -------------------------------------------------------------------------------- /src-bridge/request-handler.js: -------------------------------------------------------------------------------- 1 | const urlencode = require('urlencode'); 2 | const uuid = require('uuid'); 3 | 4 | const defaultLogger = require('./logger'); 5 | const norm = require('./normalize-headers'); 6 | 7 | const encodeBody = body => (typeof body === 'string' 8 | ? body 9 | : JSON.stringify(body)); 10 | 11 | const path = params => 12 | `/${params[0] || params.proxy || ''}` 13 | .replace(/%2f/gi, '/'); 14 | 15 | const splitHostPort = host => { 16 | const parts = typeof host === 'string' ? host.split(':') : []; 17 | return { host: parts[0], port: parts[1] }; 18 | }; 19 | 20 | module.exports = ({ 21 | pool, 22 | requestPort, 23 | logger = defaultLogger 24 | }) => function requestHandler({ 25 | body, 26 | headers = {}, 27 | httpMethod, 28 | id = uuid.v4(), 29 | method = httpMethod, 30 | pathParameters, 31 | queryStringParameters = {}, 32 | requestContext = {}, 33 | }, context, callback) { 34 | const { host, port } = splitHostPort(headers.Host || headers.host); 35 | const { sourceIp } = requestContext.identity || {}; 36 | const req = { 37 | body: encodeBody(body), 38 | headers: norm(headers), 39 | host, 40 | method, 41 | path: path(pathParameters || {}), 42 | port: parseInt(headers['X-Forwarded-Port'] || port || 80, 10), // Assume port 80, if none given. 43 | queryParams: queryStringParameters, 44 | queryString: `?${urlencode.stringify(queryStringParameters)}`, 45 | remoteIp: sourceIp || '127.0.0.1', 46 | scheme: headers['X-Forwarded-Proto'] || 'http', 47 | stage: requestContext.stage || 'local', 48 | }; 49 | 50 | pool.put(id, req, callback); 51 | logger.info(JSON.stringify({ req }, null, 2)); 52 | requestPort.send([id, req]); 53 | }; 54 | -------------------------------------------------------------------------------- /src-bridge/response-handler.js: -------------------------------------------------------------------------------- 1 | const defaultLogger = require('./logger'); 2 | 3 | const missingStatusCodeBody = 'Application did not return a valid status code'; 4 | 5 | const defaultHeaders = (body) => (typeof body === 'object' 6 | ? { 'content-type': 'application/json; charset=utf-8' } 7 | : { 'content-type': 'text/text; charset=utf-8' }); 8 | 9 | const encodeBody = (body) => { 10 | switch (typeof body) { 11 | case 'string': 12 | case 'undefined': 13 | return body; 14 | case 'object': 15 | return JSON.stringify(body); 16 | default: 17 | return `${body}`; 18 | } 19 | }; 20 | 21 | const handler = ({ pool, logger = defaultLogger }) => function responseHandler(id, resp) { 22 | logger.info(JSON.stringify({ resp }, null, 2)); 23 | const { callback } = pool.take(id); 24 | const statusCode = parseInt(resp.statusCode, 10); 25 | if (callback) { 26 | if (isNaN(statusCode)) { 27 | callback(null, { 28 | statusCode: 500, 29 | body: `${missingStatusCodeBody}: ${resp.statusCode}`, 30 | headers: defaultHeaders(''), 31 | isBase64Encoded: !!resp.isBase64Encoded 32 | }); 33 | } else { 34 | callback(null, { 35 | statusCode, 36 | body: encodeBody(resp.body, statusCode), 37 | headers: resp.headers || defaultHeaders(resp.body), 38 | isBase64Encoded: !!resp.isBase64Encoded 39 | }); 40 | } 41 | } else { 42 | logger.error('resp missing callback:', id); 43 | } 44 | }; 45 | 46 | module.exports = Object.assign(handler, { 47 | defaultHeaders, 48 | missingStatusCodeBody, 49 | }); 50 | -------------------------------------------------------------------------------- /src-bridge/validate.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | 3 | const hasFunction = (obj, attr) => ( 4 | (typeof obj === 'object') && 5 | (typeof obj[attr] === 'function')); 6 | 7 | const inspect = (val) => util.inspect(val, { depth: 2 }); 8 | 9 | const validate = (obj, attr, { missing, invalid }) => { 10 | if (!hasFunction(obj, attr)) { 11 | throw new Error(obj === undefined 12 | ? missing 13 | : `${invalid}: ${inspect(obj)}`); 14 | } 15 | }; 16 | 17 | module.exports = validate; 18 | module.exports.inspect = inspect; 19 | -------------------------------------------------------------------------------- /src-bridge/xmlhttprequest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object. 3 | * 4 | * This can be used with JS designed for browsers to improve reuse of code and 5 | * allow the use of existing libraries. 6 | * 7 | * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs. 8 | * 9 | * @author Dan DeFelippi 10 | * @contributor David Ellis 11 | * @license MIT 12 | */ 13 | 14 | var Url = require("url"); 15 | var spawn = require("child_process").spawn; 16 | var fs = require("fs"); 17 | 18 | exports.XMLHttpRequest = function() { 19 | "use strict"; 20 | 21 | /** 22 | * Private variables 23 | */ 24 | var self = this; 25 | var http = require("http"); 26 | var https = require("https"); 27 | 28 | // Holds http.js objects 29 | var request; 30 | var response; 31 | 32 | // Request settings 33 | var settings = {}; 34 | 35 | // Disable header blacklist. 36 | // Not part of XHR specs. 37 | var disableHeaderCheck = false; 38 | 39 | // Set some default headers 40 | var defaultHeaders = { 41 | "User-Agent": "node-XMLHttpRequest", 42 | "Accept": "*/*", 43 | }; 44 | 45 | var headers = {}; 46 | var headersCase = {}; 47 | 48 | // These headers are not user setable. 49 | // The following are allowed but banned in the spec: 50 | // * user-agent 51 | var forbiddenRequestHeaders = [ 52 | "accept-charset", 53 | "accept-encoding", 54 | "access-control-request-headers", 55 | "access-control-request-method", 56 | "connection", 57 | "content-length", 58 | "content-transfer-encoding", 59 | "cookie", 60 | "cookie2", 61 | "date", 62 | "expect", 63 | "host", 64 | "keep-alive", 65 | "origin", 66 | "referer", 67 | "te", 68 | "trailer", 69 | "transfer-encoding", 70 | "upgrade", 71 | "via" 72 | ]; 73 | 74 | // These request methods are not allowed 75 | var forbiddenRequestMethods = [ 76 | "TRACE", 77 | "TRACK", 78 | "CONNECT" 79 | ]; 80 | 81 | // Send flag 82 | var sendFlag = false; 83 | // Error flag, used when errors occur or abort is called 84 | var errorFlag = false; 85 | 86 | // Event listeners 87 | var listeners = {}; 88 | 89 | /** 90 | * Constants 91 | */ 92 | 93 | this.UNSENT = 0; 94 | this.OPENED = 1; 95 | this.HEADERS_RECEIVED = 2; 96 | this.LOADING = 3; 97 | this.DONE = 4; 98 | 99 | /** 100 | * Public vars 101 | */ 102 | 103 | // Current state 104 | this.readyState = this.UNSENT; 105 | 106 | // default ready state change handler in case one is not set or is set late 107 | this.onreadystatechange = null; 108 | 109 | // Result & response 110 | this.responseText = ""; 111 | this.responseXML = ""; 112 | this.status = null; 113 | this.statusText = ""; // Setting to "" so is a valid Elm string. 114 | this.responseURL = ""; // Setting to "" so is a valid Elm string. 115 | 116 | // Whether cross-site Access-Control requests should be made using 117 | // credentials such as cookies or authorization headers 118 | this.withCredentials = false; 119 | 120 | /** 121 | * Private methods 122 | */ 123 | 124 | /** 125 | * Check if the specified header is allowed. 126 | * 127 | * @param string header Header to validate 128 | * @return boolean False if not allowed, otherwise true 129 | */ 130 | var isAllowedHttpHeader = function(header) { 131 | return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); 132 | }; 133 | 134 | /** 135 | * Check if the specified method is allowed. 136 | * 137 | * @param string method Request method to validate 138 | * @return boolean False if not allowed, otherwise true 139 | */ 140 | var isAllowedHttpMethod = function(method) { 141 | return (method && forbiddenRequestMethods.indexOf(method) === -1); 142 | }; 143 | 144 | /** 145 | * Public methods 146 | */ 147 | 148 | /** 149 | * Open the connection. Currently supports local server requests. 150 | * 151 | * @param string method Connection method (eg GET, POST) 152 | * @param string url URL for the connection. 153 | * @param boolean async Asynchronous connection. Default is true. 154 | * @param string user Username for basic authentication (optional) 155 | * @param string password Password for basic authentication (optional) 156 | */ 157 | this.open = function(method, url, async, user, password) { 158 | this.abort(); 159 | errorFlag = false; 160 | 161 | // Check for valid request method 162 | if (!isAllowedHttpMethod(method)) { 163 | throw new Error("SecurityError: Request method not allowed"); 164 | } 165 | 166 | settings = { 167 | "method": method, 168 | "url": url.toString(), 169 | "async": (typeof async !=="boolean" ? true : async), 170 | "user": user || null, 171 | "password": password || null 172 | }; 173 | 174 | setState(this.OPENED); 175 | }; 176 | 177 | /** 178 | * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. 179 | * This does not conform to the W3C spec. 180 | * 181 | * @param boolean state Enable or disable header checking. 182 | */ 183 | this.setDisableHeaderCheck = function(state) { 184 | disableHeaderCheck = state; 185 | }; 186 | 187 | /** 188 | * Sets a header for the request or appends the value if one is already set. 189 | * 190 | * @param string header Header name 191 | * @param string value Header value 192 | */ 193 | this.setRequestHeader = function(header, value) { 194 | if (this.readyState !== this.OPENED) { 195 | throw new Error("INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"); 196 | } 197 | if (!isAllowedHttpHeader(header)) { 198 | console.warn("Refused to set unsafe header \"" + header + "\""); 199 | return; 200 | } 201 | if (sendFlag) { 202 | throw new Error("INVALID_STATE_ERR: send flag is true"); 203 | } 204 | header = headersCase[header.toLowerCase()] || header; 205 | headersCase[header.toLowerCase()] = header; 206 | headers[header] = headers[header] ? headers[header] + ', ' + value : value; 207 | }; 208 | 209 | /** 210 | * Gets a header from the server response. 211 | * 212 | * @param string header Name of header to get. 213 | * @return string Text of the header or null if it doesn't exist. 214 | */ 215 | this.getResponseHeader = function(header) { 216 | if (typeof header === "string" && 217 | this.readyState > this.OPENED && 218 | response && 219 | response.headers && 220 | response.headers[header.toLowerCase()] && 221 | !errorFlag 222 | ) { 223 | return response.headers[header.toLowerCase()]; 224 | } 225 | 226 | return null; 227 | }; 228 | 229 | /** 230 | * Gets all the response headers. 231 | * 232 | * @return string A string with all response headers separated by CR+LF 233 | */ 234 | this.getAllResponseHeaders = function() { 235 | if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { 236 | return ""; 237 | } 238 | var result = ""; 239 | 240 | for (var i in response.headers) { 241 | // Cookie headers are excluded 242 | if (i !== "set-cookie" && i !== "set-cookie2") { 243 | result += i + ": " + response.headers[i] + "\r\n"; 244 | } 245 | } 246 | return result.substr(0, result.length - 2); 247 | }; 248 | 249 | /** 250 | * Gets a request header 251 | * 252 | * @param string name Name of header to get 253 | * @return string Returns the request header or empty string if not set 254 | */ 255 | this.getRequestHeader = function(name) { 256 | if (typeof name === "string" && headersCase[name.toLowerCase()]) { 257 | return headers[headersCase[name.toLowerCase()]]; 258 | } 259 | 260 | return ""; 261 | }; 262 | 263 | /** 264 | * Sends the request to the server. 265 | * 266 | * @param string data Optional data to send as request body. 267 | */ 268 | this.send = function(data) { 269 | if (this.readyState !== this.OPENED) { 270 | throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); 271 | } 272 | 273 | if (sendFlag) { 274 | throw new Error("INVALID_STATE_ERR: send has already been called"); 275 | } 276 | 277 | var ssl = false, 278 | local = false; 279 | var url = Url.parse(settings.url); 280 | var host; 281 | // Determine the server 282 | switch (url.protocol) { 283 | case "https:": 284 | ssl = true; 285 | // SSL & non-SSL both need host, no break here. 286 | case "http:": 287 | host = url.hostname; 288 | break; 289 | 290 | case "file:": 291 | local = true; 292 | break; 293 | 294 | case undefined: 295 | case null: 296 | case "": 297 | host = "localhost"; 298 | break; 299 | 300 | default: 301 | throw new Error("Protocol not supported."); 302 | } 303 | 304 | // Load files off the local filesystem (file://) 305 | if (local) { 306 | if (settings.method !== "GET") { 307 | throw new Error("XMLHttpRequest: Only GET method is supported"); 308 | } 309 | 310 | if (settings.async) { 311 | fs.readFile(url.pathname, "utf8", function(error, data) { 312 | if (error) { 313 | self.handleError(error); 314 | } else { 315 | self.status = 200; 316 | self.responseText = data; 317 | setState(self.DONE); 318 | } 319 | }); 320 | } else { 321 | try { 322 | this.responseText = fs.readFileSync(url.pathname, "utf8"); 323 | this.status = 200; 324 | setState(self.DONE); 325 | } catch (e) { 326 | this.handleError(e); 327 | } 328 | } 329 | 330 | return; 331 | } 332 | 333 | // Default to port 80. If accessing localhost on another port be sure 334 | // to use http://localhost:port/path 335 | var port = url.port || (ssl ? 443 : 80); 336 | // Add query string if one is used 337 | var uri = url.pathname + (url.search ? url.search : ""); 338 | 339 | // Set the defaults if they haven't been set 340 | for (var name in defaultHeaders) { 341 | if (!headersCase[name.toLowerCase()]) { 342 | headers[name] = defaultHeaders[name]; 343 | } 344 | } 345 | 346 | // Set the Host header or the server may reject the request 347 | headers.Host = host; 348 | if (!((ssl && port === 443) || port === 80)) { 349 | headers.Host += ":" + url.port; 350 | } 351 | 352 | // Set Basic Auth if necessary 353 | if (settings.user) { 354 | if (typeof settings.password === "undefined") { 355 | settings.password = ""; 356 | } 357 | var authBuf = new Buffer(settings.user + ":" + settings.password); 358 | headers.Authorization = "Basic " + authBuf.toString("base64"); 359 | } 360 | 361 | // Set content length header 362 | if (settings.method === "GET" || settings.method === "HEAD") { 363 | data = null; 364 | } else if (data) { 365 | headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); 366 | 367 | if (!headers["Content-Type"]) { 368 | headers["Content-Type"] = "text/plain;charset=UTF-8"; 369 | } 370 | } else if (settings.method === "POST") { 371 | // For a post with no data set Content-Length: 0. 372 | // This is required by buggy servers that don't meet the specs. 373 | headers["Content-Length"] = 0; 374 | } 375 | 376 | var options = { 377 | host: host, 378 | port: port, 379 | path: uri, 380 | method: settings.method, 381 | headers: headers, 382 | agent: false, 383 | withCredentials: self.withCredentials 384 | }; 385 | 386 | // Reset error flag 387 | errorFlag = false; 388 | 389 | // Handle async requests 390 | if (settings.async) { 391 | // Use the proper protocol 392 | var doRequest = ssl ? https.request : http.request; 393 | 394 | // Request is being sent, set send flag 395 | sendFlag = true; 396 | 397 | // As per spec, this is called here for historical reasons. 398 | self.dispatchEvent("readystatechange"); 399 | 400 | // Handler for the response 401 | var responseHandler = function responseHandler(resp) { 402 | // Set response var to the response we got back 403 | // This is so it remains accessable outside this scope 404 | response = resp; 405 | // Check for redirect 406 | // @TODO Prevent looped redirects 407 | if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { 408 | // Change URL to the redirect location 409 | settings.url = response.headers.location; 410 | var url = Url.parse(settings.url); 411 | // Set host var in case it's used later 412 | host = url.hostname; 413 | // Options for the new request 414 | var newOptions = { 415 | hostname: url.hostname, 416 | port: url.port, 417 | path: url.path, 418 | method: response.statusCode === 303 ? "GET" : settings.method, 419 | headers: headers, 420 | withCredentials: self.withCredentials 421 | }; 422 | 423 | // Issue the new request 424 | request = doRequest(newOptions, responseHandler).on("error", errorHandler); 425 | request.end(); 426 | // @TODO Check if an XHR event needs to be fired here 427 | return; 428 | } 429 | 430 | response.setEncoding("utf8"); 431 | 432 | setState(self.HEADERS_RECEIVED); 433 | self.status = response.statusCode; 434 | 435 | response.on("data", function(chunk) { 436 | // Make sure there's some data 437 | if (chunk) { 438 | self.responseText += chunk; 439 | } 440 | // Don't emit state changes if the connection has been aborted. 441 | if (sendFlag) { 442 | setState(self.LOADING); 443 | } 444 | }); 445 | 446 | response.on("end", function() { 447 | if (sendFlag) { 448 | // Discard the end event if the connection has been aborted 449 | setState(self.DONE); 450 | sendFlag = false; 451 | } 452 | 453 | //self.logResponse(response); 454 | }); 455 | 456 | response.on("error", function(error) { 457 | self.handleError(error); 458 | }); 459 | }; 460 | 461 | // Error handler for the request 462 | var errorHandler = function errorHandler(error) { 463 | self.handleError(error); 464 | }; 465 | 466 | // Create the request 467 | request = doRequest(options, responseHandler).on("error", errorHandler); 468 | 469 | // Node 0.4 and later won't accept empty data. Make sure it's needed. 470 | if (data) { 471 | request.write(data); 472 | } 473 | 474 | request.end(); 475 | 476 | //self.logRequest(options, url, data, request); 477 | 478 | 479 | self.dispatchEvent("loadstart"); 480 | } else { // Synchronous 481 | // Create a temporary file for communication with the other Node process 482 | var contentFile = ".node-xmlhttprequest-content-" + process.pid; 483 | var syncFile = ".node-xmlhttprequest-sync-" + process.pid; 484 | fs.writeFileSync(syncFile, "", "utf8"); 485 | // The async request the other Node process executes 486 | var execString = "var http = require('http'), https = require('https'), fs = require('fs');" + 487 | "var doRequest = http" + (ssl ? "s" : "") + ".request;" + 488 | "var options = " + JSON.stringify(options) + ";" + 489 | "var responseText = '';" + 490 | "var req = doRequest(options, function(response) {" + 491 | "response.setEncoding('utf8');" + 492 | "response.on('data', function(chunk) {" + 493 | " responseText += chunk;" + 494 | "});" + 495 | "response.on('end', function() {" + 496 | "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText}}), 'utf8');" + 497 | "fs.unlinkSync('" + syncFile + "');" + 498 | "});" + 499 | "response.on('error', function(error) {" + 500 | "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: error}), 'utf8');" + 501 | "fs.unlinkSync('" + syncFile + "');" + 502 | "});" + 503 | "}).on('error', function(error) {" + 504 | "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: error}), 'utf8');" + 505 | "fs.unlinkSync('" + syncFile + "');" + 506 | "});" + 507 | (data ? "req.write('" + JSON.stringify(data).slice(1, -1).replace(/'/g, "\\'") + "');" : "") + 508 | "req.end();"; 509 | // Start the other Node Process, executing this string 510 | var syncProc = spawn(process.argv[0], ["-e", execString]); 511 | while (fs.existsSync(syncFile)) { 512 | // Wait while the sync file is empty 513 | } 514 | var resp = JSON.parse(fs.readFileSync(contentFile, 'utf8')); 515 | // Kill the child process once the file has data 516 | syncProc.stdin.end(); 517 | // Remove the temporary file 518 | fs.unlinkSync(contentFile); 519 | 520 | if (resp.err) { 521 | self.handleError(resp.err); 522 | } else { 523 | response = resp.data; 524 | self.status = resp.data.statusCode; 525 | self.responseText = resp.data.text; 526 | setState(self.DONE); 527 | } 528 | } 529 | }; 530 | 531 | // this.logRequest = function(options, url, data, request) { 532 | // var output = 533 | // "\n--> REQUEST XMLHttpRequest -- " + url.protocol + "//" + options.host + ":" + options.port + options.path + 534 | // "\n" + request.output.toString() + 535 | // "\n--> -- -- --"; 536 | // 537 | // console.log(output); 538 | // }; 539 | 540 | // this.logResponse = function(response) { 541 | // var headers = ''; 542 | // 543 | // Array.from(Object.keys(response.headers)).forEach(function(key) { 544 | // headers += key + ":" + response.headers[key] + "\n"; 545 | // }); 546 | // 547 | // var output = 548 | // "\n<-- RESPONSE XMLHttpRequest -- " + response.statusCode + " " + response.statusMessage + 549 | // "\n" + headers + 550 | // "\n" + self.responseText + 551 | // "\n<-- -- -- --"; 552 | // 553 | // console.log(output); 554 | // }; 555 | 556 | /** 557 | * Called when an error is encountered to deal with it. 558 | */ 559 | this.handleError = function(error) { 560 | //console.log("\n#-- ERROR XMLHttpRequest -- " + error); 561 | 562 | this.status = 0; 563 | this.statusText = error; 564 | this.responseText = error.stack; 565 | errorFlag = true; 566 | setState(this.DONE); 567 | this.dispatchEvent('error'); 568 | }; 569 | 570 | /** 571 | * Aborts a request. 572 | */ 573 | this.abort = function() { 574 | if (request) { 575 | request.abort(); 576 | request = null; 577 | } 578 | 579 | headers = defaultHeaders; 580 | this.status = 0; 581 | this.responseText = ""; 582 | this.responseXML = ""; 583 | 584 | errorFlag = true; 585 | 586 | if (this.readyState !== this.UNSENT && 587 | (this.readyState !== this.OPENED || sendFlag) && 588 | this.readyState !== this.DONE) { 589 | sendFlag = false; 590 | setState(this.DONE); 591 | } 592 | this.readyState = this.UNSENT; 593 | this.dispatchEvent('abort'); 594 | }; 595 | 596 | /** 597 | * Adds an event listener. Preferred method of binding to events. 598 | */ 599 | this.addEventListener = function(event, callback) { 600 | if (!(event in listeners)) { 601 | listeners[event] = []; 602 | } 603 | // Currently allows duplicate callbacks. Should it? 604 | listeners[event].push(callback); 605 | }; 606 | 607 | /** 608 | * Remove an event callback that has already been bound. 609 | * Only works on the matching funciton, cannot be a copy. 610 | */ 611 | this.removeEventListener = function(event, callback) { 612 | if (event in listeners) { 613 | // Filter will return a new array with the callback removed 614 | listeners[event] = listeners[event].filter(function(ev) { 615 | return ev !== callback; 616 | }); 617 | } 618 | }; 619 | 620 | /** 621 | * Dispatch any events, including both "on" methods and events attached using addEventListener. 622 | */ 623 | this.dispatchEvent = function(event) { 624 | if (typeof self["on" + event] === "function") { 625 | self["on" + event](); 626 | } 627 | if (event in listeners) { 628 | for (var i = 0, len = listeners[event].length; i < len; i++) { 629 | listeners[event][i].call(self); 630 | } 631 | } 632 | }; 633 | 634 | /** 635 | * Changes readyState and calls onreadystatechange. 636 | * 637 | * @param int state New state 638 | */ 639 | var setState = function(state) { 640 | if (state == self.LOADING || self.readyState !== state) { 641 | self.readyState = state; 642 | 643 | if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { 644 | self.dispatchEvent("readystatechange"); 645 | } 646 | 647 | if (self.readyState === self.DONE && !errorFlag) { 648 | self.response = self.responseText; 649 | self.dispatchEvent("load"); 650 | // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) 651 | self.dispatchEvent("loadend"); 652 | } 653 | } 654 | }; 655 | }; 656 | -------------------------------------------------------------------------------- /src/Serverless.elm: -------------------------------------------------------------------------------- 1 | module Serverless exposing 2 | ( httpApi, HttpApi, Program 3 | , IO, RequestPort, ResponsePort 4 | , noConfig, noRoutes, noSideEffects, noPorts 5 | , InteropRequestPort, InteropResponsePort, interop 6 | ) 7 | 8 | {-| Use `httpApi` to define a `Program` that responds to HTTP requests. Take a look 9 | at the [demos](https://github.com/ktonon/elm-serverless/blob/master/demo) 10 | for usage examples. 11 | 12 | 13 | ## Table of Contents 14 | 15 | - [Defining a Program](#defining-a-program) 16 | - [Port Types](#port-types) 17 | - [Initialization Helpers](#initialization-helpers) 18 | 19 | 20 | ## Defining a Program 21 | 22 | Use `httpApi` to define a headless Elm program. 23 | 24 | @docs httpApi, HttpApi, Program 25 | 26 | 27 | ## Port Types 28 | 29 | Since a library cannot expose ports, your application must define two ports 30 | with the following signatures. See the 31 | [Hello World Demo](https://github.com/ktonon/elm-serverless/blob/master/demo/src/Hello) 32 | for a usage example. 33 | 34 | @docs IO, RequestPort, ResponsePort 35 | 36 | 37 | ## Initialization Helpers 38 | 39 | Various aspects of Program may not be needed. These functions are provided as a 40 | convenient way to opt-out. 41 | 42 | @docs noConfig, noRoutes, noSideEffects, noPorts 43 | 44 | 45 | ## Interop ports and helpers. 46 | 47 | @docs InteropRequestPort, InteropResponsePort, interop 48 | 49 | -} 50 | 51 | import Json.Decode exposing (Decoder, decodeValue) 52 | import Json.Encode exposing (Value) 53 | import Serverless.Conn as Conn exposing (Conn, Id) 54 | import Serverless.Conn.Body as Body 55 | import Serverless.Conn.Pool as ConnPool 56 | import Serverless.Conn.Request as Request 57 | import Serverless.Conn.Response as Response exposing (Status) 58 | import Url exposing (Url) 59 | 60 | 61 | {-| Serverless program type. 62 | 63 | This maps to a headless elm 64 | [Platform.Program](http://package.elm-lang.org/packages/elm-lang/core/latest/Platform#Program). 65 | 66 | -} 67 | type alias Program config model route msg = 68 | Platform.Program Flags (Model config model route msg) (Msg msg) 69 | 70 | 71 | {-| Type of flags for program. 72 | 73 | `Value` is a 74 | [Json.Encode.Value](http://package.elm-lang.org/packages/elm-lang/core/latest/Json-Encode#Value). 75 | The program configuration (`config`) is passed in as flags. 76 | 77 | -} 78 | type alias Flags = 79 | Json.Encode.Value 80 | 81 | 82 | {-| Create a program from the given HTTP api. 83 | -} 84 | httpApi : 85 | HttpApi config model route msg 86 | -> Program config model route msg 87 | httpApi api = 88 | Platform.worker 89 | { init = init_ api 90 | , update = update_ api 91 | , subscriptions = sub_ api 92 | } 93 | 94 | 95 | {-| Program for an HTTP API. 96 | 97 | A Serverless.Program is parameterized by your 5 custom types 98 | 99 | - `config` is a server load-time record of deployment specific values 100 | - `model` is for whatever you need during the processing of a request 101 | - `route` represents your application routes 102 | - `msg` is your app message type 103 | 104 | You must provide the following: 105 | 106 | - `configDecoder` decodes a JSON value for your custom config type 107 | - `requestPort` and `responsePort` must be defined in your app since an elm library cannot expose ports 108 | - `initialModel` is a value to which new connections will set their model 109 | - `parseRoute` takes the `request/path/and?query=string` and parses it into a `route` 110 | - `endpoint` is a function which receives incoming connections 111 | - `update` the app update function 112 | 113 | Notices that `update` and `endpoint` operate on `Conn config model route msg` 114 | and not just on `model`. 115 | 116 | -} 117 | type alias HttpApi config model route msg = 118 | { configDecoder : Decoder config 119 | , initialModel : model 120 | , parseRoute : Url -> Maybe route 121 | , endpoint : Conn config model route msg -> ( Conn config model route msg, Cmd msg ) 122 | , update : msg -> Conn config model route msg -> ( Conn config model route msg, Cmd msg ) 123 | , requestPort : RequestPort (Msg msg) 124 | , responsePort : ResponsePort (Msg msg) 125 | , interopPorts : List (InteropResponsePort (Msg msg)) 126 | } 127 | 128 | 129 | {-| Describes input and output events over ports as a connection id paired with 130 | a data value. 131 | -} 132 | type alias IO = 133 | ( String, Json.Encode.Value ) 134 | 135 | 136 | {-| Type of port through which the request is received. 137 | Set your request port to this type. 138 | 139 | port requestPort : RequestPort msg 140 | 141 | -} 142 | type alias RequestPort msg = 143 | (IO -> msg) -> Sub msg 144 | 145 | 146 | {-| Type of port through which the request is sent. 147 | Set your response port to this type. 148 | 149 | port responsePort : ResponsePort msg 150 | 151 | -} 152 | type alias ResponsePort msg = 153 | IO -> Cmd msg 154 | 155 | 156 | 157 | -- OPT-OUT PROGRAM INITIALIZERS 158 | 159 | 160 | {-| Opt-out of configuration decoding. 161 | 162 | main : Serverless.Program () model route msg 163 | main = 164 | Serverless.httpApi 165 | { configDecoder = noConfig 166 | 167 | -- ... 168 | } 169 | 170 | -} 171 | noConfig : Json.Decode.Decoder () 172 | noConfig = 173 | Json.Decode.succeed () 174 | 175 | 176 | {-| Opt-out of route parsing. 177 | 178 | main : Serverless.Program config model () msg 179 | main = 180 | Serverless.httpApi 181 | { parseRoute = noRoutes 182 | 183 | -- ... 184 | } 185 | 186 | -} 187 | noRoutes : Url -> Maybe () 188 | noRoutes _ = 189 | Just () 190 | 191 | 192 | {-| Opt-out of side-effects. 193 | 194 | main : Serverless.Program config model route () 195 | main = 196 | Serverless.httpApi 197 | { update = noSideEffects 198 | 199 | -- ... 200 | } 201 | 202 | -} 203 | noSideEffects : 204 | () 205 | -> Conn config model route msg 206 | -> ( Conn config model route msg, Cmd () ) 207 | noSideEffects _ conn = 208 | ( conn, Cmd.none ) 209 | 210 | 211 | {-| Opt-out of interop ports. 212 | 213 | main : Serverless.Program config model route () 214 | main = 215 | Serverless.httpApi 216 | { ports = noPorts 217 | 218 | -- ... 219 | } 220 | 221 | -} 222 | noPorts : List (InteropResponsePort (Msg msg)) 223 | noPorts = 224 | [] 225 | 226 | 227 | 228 | -- IMPLEMENTATION 229 | 230 | 231 | type alias Model config model route msg = 232 | { pool : ConnPool.Pool config model route msg 233 | , configResult : Result String config 234 | } 235 | 236 | 237 | type Msg msg 238 | = RequestPortMsg IO 239 | | HandlerMsg Id msg 240 | | InteropHandlerMsg Id Int Value 241 | | HandlerDecodeErr Id Json.Decode.Error 242 | 243 | 244 | type SlsMsg config model route msg 245 | = RequestAdd (Conn config model route msg) 246 | | RequestUpdate Id msg 247 | | RequestInteropResponse Id Int Value 248 | | ProcessingError Id Int Bool String 249 | 250 | 251 | init_ : 252 | HttpApi config model route msg 253 | -> Flags 254 | -> ( Model config model route msg, Cmd (Msg msg) ) 255 | init_ api flags = 256 | case decodeValue api.configDecoder flags of 257 | Ok config -> 258 | ( { pool = ConnPool.empty 259 | , configResult = Ok config 260 | } 261 | , Cmd.none 262 | ) 263 | 264 | Err err -> 265 | ( { pool = ConnPool.empty 266 | , configResult = Err <| Json.Decode.errorToString err 267 | } 268 | , Cmd.none 269 | ) 270 | 271 | 272 | toSlsMsg : 273 | HttpApi config model route msg 274 | -> Result String config 275 | -> Msg msg 276 | -> SlsMsg config model route msg 277 | toSlsMsg api configResult rawMsg = 278 | case ( configResult, rawMsg ) of 279 | ( Err err, RequestPortMsg ( id, _ ) ) -> 280 | ProcessingError id 500 True <| 281 | (++) "Failed to parse configuration flags. " err 282 | 283 | ( Ok config, RequestPortMsg ( id, raw ) ) -> 284 | case decodeValue Request.decoder raw of 285 | Ok req -> 286 | case 287 | Request.url req 288 | |> Url.fromString 289 | |> Maybe.andThen api.parseRoute 290 | of 291 | Just route -> 292 | RequestAdd <| Conn.init id config api.initialModel route req 293 | 294 | Nothing -> 295 | ProcessingError id 404 False <| 296 | (++) "Could not parse route: " 297 | (Request.path req) 298 | 299 | Err err -> 300 | ProcessingError id 500 False <| 301 | (++) "Misconfigured server. Make sure the elm-serverless npm package version matches the elm package version." 302 | (Json.Decode.errorToString err) 303 | 304 | ( _, HandlerMsg id msg ) -> 305 | RequestUpdate id msg 306 | 307 | ( _, InteropHandlerMsg id seqNo val ) -> 308 | RequestInteropResponse id seqNo val 309 | 310 | ( _, HandlerDecodeErr id err ) -> 311 | ProcessingError id 500 False <| 312 | (++) "Failed to decode interop handler argument." 313 | (Json.Decode.errorToString err) 314 | 315 | 316 | update_ : 317 | HttpApi config model route msg 318 | -> Msg msg 319 | -> Model config model route msg 320 | -> ( Model config model route msg, Cmd (Msg msg) ) 321 | update_ api rawMsg model = 322 | case toSlsMsg api model.configResult rawMsg of 323 | RequestAdd conn -> 324 | updateChildHelper api 325 | (api.endpoint conn) 326 | model 327 | 328 | RequestUpdate connId msg -> 329 | updateChild api connId msg model 330 | 331 | RequestInteropResponse connId seqNo val -> 332 | updateChildForInteropResponse api connId seqNo val model 333 | 334 | ProcessingError connId status secret err -> 335 | let 336 | errMsg = 337 | if secret then 338 | "Internal Server Error. Check logs for details." 339 | 340 | else 341 | err 342 | in 343 | ( model, send api connId status errMsg ) 344 | 345 | 346 | updateChild : 347 | HttpApi config model route msg 348 | -> Id 349 | -> msg 350 | -> Model config model route msg 351 | -> ( Model config model route msg, Cmd (Msg msg) ) 352 | updateChild api connId msg model = 353 | case ConnPool.get connId model.pool of 354 | Just conn -> 355 | updateChildHelper api (api.update msg conn) model 356 | 357 | _ -> 358 | ( model 359 | , send api connId 500 <| 360 | (++) "No connection in pool with id: " connId 361 | ) 362 | 363 | 364 | updateChildForInteropResponse : 365 | HttpApi config model route msg 366 | -> Id 367 | -> Int 368 | -> Value 369 | -> Model config model route msg 370 | -> ( Model config model route msg, Cmd (Msg msg) ) 371 | updateChildForInteropResponse api connId seqNo val model = 372 | case ConnPool.get connId model.pool of 373 | Just conn -> 374 | let 375 | -- Find the message builder by seq no. 376 | -- Clean up the sequence number on the connection. 377 | ( maybeMsgBuilder, newConn ) = 378 | Conn.consumeInteropContext seqNo conn 379 | in 380 | case maybeMsgBuilder of 381 | Just msgBuilder -> 382 | -- Run `update` on the connection with the message. 383 | updateChildHelper api (api.update (msgBuilder val) newConn) model 384 | 385 | Nothing -> 386 | ( model 387 | , send api connId 500 <| 388 | "No message builder for interop seqNo " 389 | ++ String.fromInt seqNo 390 | ++ " on pool with id: " 391 | ++ connId 392 | ) 393 | 394 | _ -> 395 | ( model 396 | , send api connId 500 <| 397 | "No connection in pool with id: " 398 | ++ connId 399 | ) 400 | 401 | 402 | updateChildHelper : 403 | HttpApi config model route msg 404 | -> ( Conn config model route msg, Cmd msg ) 405 | -> Model config model route msg 406 | -> ( Model config model route msg, Cmd (Msg msg) ) 407 | updateChildHelper api ( conn, cmd ) model = 408 | case Conn.unsent conn of 409 | Nothing -> 410 | ( { model | pool = model.pool |> ConnPool.remove conn } 411 | , api.responsePort 412 | ( Conn.id conn 413 | , Conn.jsonEncodedResponse conn 414 | ) 415 | ) 416 | 417 | Just unsentConn -> 418 | ( { model 419 | | pool = 420 | ConnPool.replace 421 | unsentConn 422 | model.pool 423 | } 424 | , Cmd.map (HandlerMsg (Conn.id conn)) cmd 425 | ) 426 | 427 | 428 | sub_ : 429 | HttpApi config model route msg 430 | -> Model config model route msg 431 | -> Sub (Msg msg) 432 | sub_ api model = 433 | let 434 | responseMap : InteropResponsePort (Msg msg) -> Sub (Msg msg) 435 | responseMap interopResponsePort = 436 | interopResponsePort (\( id, interopSeqNo, val ) -> InteropHandlerMsg id interopSeqNo val) 437 | 438 | interopSubs : List (Sub (Msg msg)) 439 | interopSubs = 440 | List.map (\interopPort -> responseMap interopPort) api.interopPorts 441 | in 442 | Sub.batch 443 | (api.requestPort RequestPortMsg 444 | :: interopSubs 445 | ) 446 | 447 | 448 | 449 | -- JS Interop 450 | 451 | 452 | {-| The type of all incoming interop ports. 453 | -} 454 | type alias InteropResponsePort msg = 455 | (( String, Int, Json.Encode.Value ) -> msg) -> Sub msg 456 | 457 | 458 | {-| The type of all outgoing interop ports. 459 | -} 460 | type alias InteropRequestPort a msg = 461 | ( String, Int, a ) -> Cmd msg 462 | 463 | 464 | {-| Interop helper that invokes a port that follows the InteropRequestPort model. 465 | This helper function automatically passes the connection id to the port with the 466 | data. The connection id is needed if the port generates a response, in order to send 467 | the response to the correct connection. 468 | 469 | Note that it is possible to invoke any port, this is just a helper function for 470 | a default way of doing it. 471 | 472 | -} 473 | interop : InteropRequestPort a msg -> a -> (Value -> msg) -> Conn config model route msg -> ( Conn config model route msg, Cmd msg ) 474 | interop interopPort arg responseFn conn = 475 | let 476 | ( interopSeqNo, connWithContext ) = 477 | Conn.createInteropContext responseFn conn 478 | in 479 | ( connWithContext 480 | , interopPort ( Conn.id conn, interopSeqNo, arg ) 481 | ) 482 | 483 | 484 | 485 | -- HELPERS 486 | 487 | 488 | send : 489 | HttpApi config model route msg 490 | -> Id 491 | -> Status 492 | -> String 493 | -> Cmd (Msg msg) 494 | send { responsePort } id code msg = 495 | responsePort 496 | ( id 497 | , Response.init 498 | |> Response.setStatus code 499 | |> Response.setBody (Body.text msg) 500 | |> Response.encode 501 | ) 502 | -------------------------------------------------------------------------------- /src/Serverless/Conn.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn exposing 2 | ( Conn, Id 3 | , config, model, updateModel 4 | , request, id, method, header, route 5 | , respond, updateResponse, send, toSent, unsent, mapUnsent 6 | , init, jsonEncodedResponse 7 | , createInteropContext, consumeInteropContext 8 | ) 9 | 10 | {-| Functions for querying and updating connections. 11 | 12 | @docs Conn, Id 13 | 14 | 15 | ## Table of Contents 16 | 17 | - [Processing Application Data](#processing-application-data) 18 | - [Querying the Request](#querying-the-request) 19 | - [Responding](#responding) 20 | - [Response Body](#response-body) 21 | 22 | 23 | ## Processing Application Data 24 | 25 | Query and update your application specific data. 26 | 27 | @docs config, model, updateModel 28 | 29 | 30 | ## Querying the Request 31 | 32 | Get details about the HTTP request. 33 | 34 | @docs request, id, method, header, route 35 | 36 | 37 | ## Responding 38 | 39 | Update the response and send it. 40 | 41 | @docs respond, updateResponse, send, toSent, unsent, mapUnsent 42 | 43 | 44 | ## Misc 45 | 46 | These functions are typically not needed when building an application. They are 47 | used internally by the framework. They may be useful when debugging or writing 48 | unit tests. 49 | 50 | @docs init, jsonEncodedResponse 51 | @docs createInteropContext, consumeInteropContext 52 | 53 | -} 54 | 55 | import Dict exposing (Dict) 56 | import Json.Encode exposing (Value) 57 | import Serverless.Conn.Body as Body exposing (Body) 58 | import Serverless.Conn.Request as Request exposing (Method, Request) 59 | import Serverless.Conn.Response as Response exposing (Response, Status, setBody, setStatus) 60 | 61 | 62 | {-| A connection with a request and response. 63 | 64 | Connections are parameterized with config and model record types which are 65 | specific to the application. Config is loaded once on app startup, while model 66 | is set to a provided initial value for each incomming request. 67 | 68 | -} 69 | type Conn config model route msg 70 | = Conn (Impl config model route msg) 71 | 72 | 73 | {-| Universally unique connection identifier. 74 | -} 75 | type alias Id = 76 | String 77 | 78 | 79 | type alias Impl config model route msg = 80 | { id : Id 81 | , config : config 82 | , req : Request 83 | , resp : Sendable Response 84 | , model : model 85 | , route : route 86 | , interopSeqNo : Int 87 | , interopContext : Dict Int (Value -> msg) 88 | } 89 | 90 | 91 | type Sendable a 92 | = Unsent a 93 | | Sent Json.Encode.Value 94 | 95 | 96 | 97 | -- PROCESSING APPLICATION DATA 98 | 99 | 100 | {-| Application defined configuration 101 | -} 102 | config : Conn config model route msg -> config 103 | config (Conn conn) = 104 | conn.config 105 | 106 | 107 | {-| Application defined model 108 | -} 109 | model : Conn config model route msg -> model 110 | model (Conn conn) = 111 | conn.model 112 | 113 | 114 | {-| Transform and update the application defined model stored in the connection. 115 | -} 116 | updateModel : (model -> model) -> Conn config model route msg -> Conn config model route msg 117 | updateModel update (Conn conn) = 118 | Conn { conn | model = update conn.model } 119 | 120 | 121 | 122 | -- QUERYING THE REQUEST 123 | 124 | 125 | {-| Request 126 | -} 127 | request : Conn config model route msg -> Request 128 | request (Conn { req }) = 129 | req 130 | 131 | 132 | {-| Get a request header by name 133 | -} 134 | header : String -> Conn config model route msg -> Maybe String 135 | header key (Conn { req }) = 136 | Request.header key req 137 | 138 | 139 | {-| Request HTTP method 140 | -} 141 | method : Conn config model route msg -> Method 142 | method = 143 | request >> Request.method 144 | 145 | 146 | {-| Parsed route 147 | -} 148 | route : Conn config model route msg -> route 149 | route (Conn conn) = 150 | conn.route 151 | 152 | 153 | 154 | -- RESPONDING 155 | 156 | 157 | {-| Update a response and send it. 158 | 159 | import Serverless.Conn.Response exposing (setBody, setStatus) 160 | import TestHelpers exposing (conn, responsePort) 161 | 162 | -- The following two expressions produce the same result 163 | conn 164 | |> respond ( 200, textBody "Ok" ) 165 | --> conn 166 | --> |> updateResponse 167 | --> ((setStatus 200) >> (setBody <| textBody "Ok")) 168 | --> |> send 169 | 170 | -} 171 | respond : 172 | ( Status, Body ) 173 | -> Conn config model route msg 174 | -> ( Conn config model route msg, Cmd msg ) 175 | respond ( status, body ) = 176 | updateResponse 177 | (setStatus status >> setBody body) 178 | >> send 179 | 180 | 181 | {-| Applies the given transformation to the connection response. 182 | 183 | Does not do anything if the response has already been sent. 184 | 185 | import Serverless.Conn.Response exposing (addHeader) 186 | import TestHelpers exposing (conn, getHeader) 187 | 188 | conn 189 | |> updateResponse 190 | (addHeader ( "Cache-Control", "no-cache" )) 191 | |> getHeader "cache-control" 192 | --> Just "no-cache" 193 | 194 | -} 195 | updateResponse : 196 | (Response -> Response) 197 | -> Conn config model route msg 198 | -> Conn config model route msg 199 | updateResponse updater (Conn conn) = 200 | Conn <| 201 | case conn.resp of 202 | Unsent resp -> 203 | { conn | resp = Unsent (updater resp) } 204 | 205 | Sent _ -> 206 | conn 207 | 208 | 209 | {-| Sends a connection response through the given port 210 | -} 211 | send : 212 | Conn config model route msg 213 | -> ( Conn config model route msg, Cmd msg ) 214 | send conn = 215 | ( toSent conn, Cmd.none ) 216 | 217 | 218 | {-| Converts a conn to a sent conn, making it immutable. 219 | 220 | The connection will be sent once the current update loop completes. This 221 | function is intended to be used by middleware, which cannot issue side-effects. 222 | 223 | import TestHelpers exposing (conn) 224 | 225 | (unsent conn) == Just conn 226 | --> True 227 | 228 | (unsent <| toSent conn) == Nothing 229 | --> True 230 | 231 | -} 232 | toSent : 233 | Conn config model route msg 234 | -> Conn config model route msg 235 | toSent (Conn conn) = 236 | case conn.resp of 237 | Unsent resp -> 238 | Conn 239 | { conn | resp = Sent <| Response.encode resp } 240 | 241 | Sent _ -> 242 | Conn conn 243 | 244 | 245 | {-| Return `Just` the same can if it has not been sent yet. 246 | -} 247 | unsent : Conn config model route msg -> Maybe (Conn config model route msg) 248 | unsent (Conn conn) = 249 | case conn.resp of 250 | Sent _ -> 251 | Nothing 252 | 253 | Unsent _ -> 254 | Just <| Conn conn 255 | 256 | 257 | {-| Apply an update function to a conn, but only if the conn is unsent. 258 | -} 259 | mapUnsent : 260 | (Conn config model route msg -> ( Conn config model route msg, Cmd msg )) 261 | -> Conn config model route msg 262 | -> ( Conn config model route msg, Cmd msg ) 263 | mapUnsent func (Conn conn) = 264 | case conn.resp of 265 | Sent _ -> 266 | ( Conn conn, Cmd.none ) 267 | 268 | Unsent _ -> 269 | func (Conn conn) 270 | 271 | 272 | 273 | -- MISC 274 | 275 | 276 | {-| Universally unique Conn identifier 277 | -} 278 | id : Conn config model route msg -> Id 279 | id (Conn conn) = 280 | conn.id 281 | 282 | 283 | {-| Attemps to find the response message builder for an interop port call, by its 284 | unique sequence number, and removes this sequence number from the context store 285 | on the connection. 286 | -} 287 | consumeInteropContext : Int -> Conn config model route msg -> ( Maybe (Value -> msg), Conn config model route msg ) 288 | consumeInteropContext seqNo (Conn conn) = 289 | ( Dict.get seqNo conn.interopContext 290 | , { conn | interopContext = Dict.remove seqNo conn.interopContext } 291 | |> Conn 292 | ) 293 | 294 | 295 | {-| Adds a response message builder for an interop port call, under a unique sequence number 296 | on the connection. 297 | -} 298 | createInteropContext : (Value -> msg) -> Conn config model route msg -> ( Int, Conn config model route msg ) 299 | createInteropContext msgFn (Conn conn) = 300 | let 301 | nextSeqNo = 302 | conn.interopSeqNo + 1 303 | in 304 | ( nextSeqNo 305 | , { conn 306 | | interopSeqNo = nextSeqNo 307 | , interopContext = Dict.insert nextSeqNo msgFn conn.interopContext 308 | } 309 | |> Conn 310 | ) 311 | 312 | 313 | {-| Initialize a new Conn. 314 | -} 315 | init : Id -> config -> model -> route -> Request -> Conn config model route msg 316 | init givenId givenConfig givenModel givenRoute req = 317 | Conn 318 | { id = givenId 319 | , config = givenConfig 320 | , req = req 321 | , resp = Unsent Response.init 322 | , model = givenModel 323 | , route = givenRoute 324 | , interopSeqNo = 0 325 | , interopContext = Dict.empty 326 | } 327 | 328 | 329 | {-| Response as JSON encoded to a string. 330 | 331 | This is the format the response takes when it gets sent through the response port. 332 | 333 | -} 334 | jsonEncodedResponse : Conn config model route msg -> Json.Encode.Value 335 | jsonEncodedResponse (Conn conn) = 336 | case conn.resp of 337 | Unsent resp -> 338 | Response.encode resp 339 | 340 | Sent encodedValue -> 341 | encodedValue 342 | -------------------------------------------------------------------------------- /src/Serverless/Conn/Body.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.Body exposing 2 | ( Body 3 | , text, json, binary, empty 4 | , asJson, asText, isEmpty, contentType 5 | , decoder, encode, isBase64Encoded 6 | ) 7 | 8 | {-| 9 | 10 | 11 | ## HTTP Body 12 | 13 | @docs Body 14 | 15 | 16 | ## Response Body 17 | 18 | Use these constructors to create response bodies with different content types. 19 | 20 | @docs text, json, binary, empty 21 | 22 | 23 | ## Request Body 24 | 25 | Use these functions to check what kind of body a request has. 26 | 27 | @docs asJson, asText, isEmpty, contentType 28 | 29 | 30 | ## Misc 31 | 32 | These functions are typically not needed when building an application. They are 33 | used internally by the framework. They may be useful when debugging or writing 34 | unit tests. 35 | 36 | @docs decoder, encode, isBase64Encoded 37 | 38 | -} 39 | 40 | import Json.Decode as Decode exposing (Decoder, andThen) 41 | import Json.Encode as Encode 42 | 43 | 44 | {-| An HTTP request or response body. 45 | -} 46 | type Body 47 | = Empty 48 | | Error String 49 | | Text String 50 | | Json Encode.Value 51 | | Binary String String 52 | 53 | 54 | 55 | -- CONSTRUCTORS 56 | 57 | 58 | {-| Create an empty body. 59 | -} 60 | empty : Body 61 | empty = 62 | Empty 63 | 64 | 65 | {-| A plain text body. 66 | -} 67 | text : String -> Body 68 | text = 69 | Text 70 | 71 | 72 | {-| A JSON body. 73 | -} 74 | json : Encode.Value -> Body 75 | json = 76 | Json 77 | 78 | 79 | {-| A binary file. 80 | -} 81 | binary : String -> String -> Body 82 | binary = 83 | Binary 84 | 85 | 86 | 87 | -- DESTRUCTURING 88 | 89 | 90 | {-| Extract the String from the body. 91 | -} 92 | asText : Body -> Result String String 93 | asText body = 94 | case body of 95 | Empty -> 96 | Ok "" 97 | 98 | Error err -> 99 | Err err 100 | 101 | Text val -> 102 | Ok val 103 | 104 | Json val -> 105 | Ok <| Encode.encode 0 val 106 | 107 | Binary _ val -> 108 | Ok val 109 | 110 | 111 | {-| Extract the JSON value from the body. 112 | -} 113 | asJson : Body -> Result String Encode.Value 114 | asJson body = 115 | case body of 116 | Empty -> 117 | Ok Encode.null 118 | 119 | Error err -> 120 | Err err 121 | 122 | Text val -> 123 | val 124 | |> Decode.decodeString Decode.value 125 | |> Result.mapError Decode.errorToString 126 | 127 | Json val -> 128 | Ok val 129 | 130 | Binary _ val -> 131 | Decode.decodeString Decode.value val 132 | |> Result.mapError Decode.errorToString 133 | 134 | 135 | 136 | -- QUERY 137 | 138 | 139 | {-| The content type of a given body. 140 | 141 | import Json.Encode 142 | 143 | contentType (text "hello") 144 | --> "text/text" 145 | 146 | contentType (json Json.Encode.null) 147 | --> "application/json" 148 | 149 | -} 150 | contentType : Body -> String 151 | contentType body = 152 | case body of 153 | Json _ -> 154 | "application/json" 155 | 156 | Binary binType _ -> 157 | binType 158 | 159 | _ -> 160 | "text/text" 161 | 162 | 163 | {-| Is the given body empty? 164 | 165 | import Json.Encode exposing (list) 166 | 167 | isEmpty (empty) 168 | --> True 169 | 170 | isEmpty (text "") 171 | --> False 172 | 173 | isEmpty (json (list [])) 174 | --> False 175 | 176 | -} 177 | isEmpty : Body -> Bool 178 | isEmpty body = 179 | case body of 180 | Empty -> 181 | True 182 | 183 | _ -> 184 | False 185 | 186 | 187 | {-| Checks if a body should be base 64 encoded. 188 | -} 189 | isBase64Encoded : Body -> Bool 190 | isBase64Encoded body = 191 | case body of 192 | Binary _ _ -> 193 | True 194 | 195 | _ -> 196 | False 197 | 198 | 199 | 200 | -- UPDATE 201 | 202 | 203 | {-| Appends text to a given body if possible. 204 | 205 | import Json.Encode exposing (list) 206 | 207 | text "foo" |> appendText "bar" 208 | --> Ok (text "foobar") 209 | 210 | empty |> appendText "to empty" 211 | --> Ok (text "to empty") 212 | 213 | json (list []) |> appendText "will fail" 214 | --> Err "cannot append text to json" 215 | 216 | -} 217 | appendText : String -> Body -> Result String Body 218 | appendText val body = 219 | case body of 220 | Empty -> 221 | Ok (Text val) 222 | 223 | Error err -> 224 | Err <| "cannot append to body with error: " ++ err 225 | 226 | Text existingVal -> 227 | Ok (Text (existingVal ++ val)) 228 | 229 | Json jval -> 230 | Err "cannot append text to json" 231 | 232 | Binary _ _ -> 233 | Err "cannot append text to binary" 234 | 235 | 236 | 237 | -- JSON 238 | 239 | 240 | {-| A decoder for an HTTP body, to JSON or plain text. 241 | -} 242 | decoder : Maybe String -> Decoder Body 243 | decoder maybeType = 244 | Decode.nullable Decode.string 245 | |> andThen 246 | (\maybeString -> 247 | case maybeString of 248 | Just w -> 249 | if maybeType |> Maybe.withDefault "" |> String.startsWith "application/json" then 250 | case Decode.decodeString Decode.value w of 251 | Ok val -> 252 | Decode.succeed <| Json val 253 | 254 | Err err -> 255 | err 256 | |> Decode.errorToString 257 | |> Error 258 | |> Decode.succeed 259 | 260 | else 261 | Decode.succeed <| Text w 262 | 263 | Nothing -> 264 | Decode.succeed Empty 265 | ) 266 | 267 | 268 | {-| An encoder for an HTTP body. 269 | -} 270 | encode : Body -> Encode.Value 271 | encode body = 272 | case body of 273 | Empty -> 274 | Encode.null 275 | 276 | Error err -> 277 | Encode.string err 278 | 279 | Text w -> 280 | Encode.string w 281 | 282 | Json j -> 283 | j 284 | 285 | Binary _ v -> 286 | Encode.string v 287 | -------------------------------------------------------------------------------- /src/Serverless/Conn/Charset.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.Charset exposing 2 | ( Charset 3 | , default, utf8 4 | , toString 5 | ) 6 | 7 | {-| A character encoding. 8 | 9 | @docs Charset 10 | 11 | @docs default, utf8 12 | 13 | @docs toString 14 | 15 | -} 16 | 17 | 18 | {-| -} 19 | type Charset 20 | = Utf8 21 | 22 | 23 | 24 | -- CONSTRUCTORS 25 | 26 | 27 | {-| -} 28 | default : Charset 29 | default = 30 | Utf8 31 | 32 | 33 | {-| -} 34 | utf8 : Charset 35 | utf8 = 36 | Utf8 37 | 38 | 39 | 40 | -- QUERY 41 | 42 | 43 | {-| -} 44 | toString : Charset -> String 45 | toString charset = 46 | case charset of 47 | Utf8 -> 48 | "utf-8" 49 | -------------------------------------------------------------------------------- /src/Serverless/Conn/IpAddress.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.IpAddress exposing 2 | ( IpAddress 3 | , ip4, loopback 4 | , decoder 5 | ) 6 | 7 | {-| Internet protocol addresses and related functions. 8 | 9 | @docs IpAddress 10 | 11 | 12 | ## Constructors 13 | 14 | @docs ip4, loopback 15 | 16 | 17 | ## Misc 18 | 19 | These functions are typically not needed when building an application. They are 20 | used internally by the framework. 21 | 22 | @docs decoder 23 | 24 | -} 25 | 26 | import Json.Decode as Decode exposing (Decoder, andThen) 27 | 28 | 29 | {-| IP address type. 30 | -} 31 | type IpAddress 32 | = Ip4 Int Int Int Int 33 | 34 | 35 | 36 | -- CONSTRUCTORS 37 | 38 | 39 | {-| Creates an IPv4 address. 40 | -} 41 | ip4 : Int -> Int -> Int -> Int -> IpAddress 42 | ip4 = 43 | Ip4 44 | 45 | 46 | {-| The loopback address. 47 | 48 | loopback 49 | --> ip4 127 0 0 1 50 | 51 | -} 52 | loopback : IpAddress 53 | loopback = 54 | Ip4 127 0 0 1 55 | 56 | 57 | 58 | -- JSON 59 | 60 | 61 | {-| JSON decoder an IP address. 62 | -} 63 | decoder : Decoder IpAddress 64 | decoder = 65 | Decode.string 66 | |> andThen 67 | (\w -> 68 | let 69 | list = 70 | w 71 | |> String.split "." 72 | |> List.map toNonNegativeInt 73 | in 74 | case list of 75 | (Just a) :: (Just b) :: (Just c) :: (Just d) :: [] -> 76 | Decode.succeed (Ip4 a b c d) 77 | 78 | _ -> 79 | Decode.fail <| "Unsupported IP address: " ++ w 80 | ) 81 | 82 | 83 | toNonNegativeInt : String -> Maybe Int 84 | toNonNegativeInt val = 85 | val 86 | |> String.toInt 87 | |> Maybe.andThen 88 | (\i -> 89 | if i >= 0 then 90 | Just i 91 | 92 | else 93 | Nothing 94 | ) 95 | -------------------------------------------------------------------------------- /src/Serverless/Conn/KeyValueList.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.KeyValueList exposing (decoder, encode) 2 | 3 | import Json.Decode as Decode exposing (Decoder, andThen, map) 4 | import Json.Encode as Encode 5 | 6 | 7 | decoder : Decoder (List ( String, String )) 8 | decoder = 9 | Decode.keyValuePairs Decode.string 10 | |> Decode.nullable 11 | |> andThen 12 | (\maybeParams -> 13 | case maybeParams of 14 | Just params -> 15 | Decode.succeed params 16 | 17 | Nothing -> 18 | Decode.succeed [] 19 | ) 20 | 21 | 22 | encode : List ( String, String ) -> Encode.Value 23 | encode params = 24 | params 25 | |> List.reverse 26 | |> List.map (\( a, b ) -> ( a, Encode.string b )) 27 | |> Encode.object 28 | -------------------------------------------------------------------------------- /src/Serverless/Conn/Pool.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.Pool exposing 2 | ( Pool 3 | , empty 4 | , get 5 | , remove 6 | , replace 7 | , size 8 | ) 9 | 10 | import Dict exposing (Dict) 11 | import Serverless.Conn as Conn exposing (Conn, Id) 12 | 13 | 14 | type Pool config model route msg 15 | = Pool 16 | { connDict : Dict Id (Conn config model route msg) 17 | } 18 | 19 | 20 | empty : Pool config model route msg 21 | empty = 22 | Pool { connDict = Dict.empty } 23 | 24 | 25 | get : 26 | Id 27 | -> Pool config model route msg 28 | -> Maybe (Conn config model route msg) 29 | get requestId (Pool { connDict }) = 30 | Dict.get requestId connDict 31 | 32 | 33 | replace : 34 | Conn config model route msg 35 | -> Pool config model route msg 36 | -> Pool config model route msg 37 | replace conn (Pool pool) = 38 | Pool 39 | { pool 40 | | connDict = 41 | Dict.insert 42 | (Conn.id conn) 43 | conn 44 | pool.connDict 45 | } 46 | 47 | 48 | remove : 49 | Conn config model route msg 50 | -> Pool config model route msg 51 | -> Pool config model route msg 52 | remove conn (Pool pool) = 53 | Pool 54 | { pool 55 | | connDict = 56 | pool.connDict 57 | |> Dict.remove (Conn.id conn) 58 | } 59 | 60 | 61 | size : Pool config model route msg -> Int 62 | size (Pool { connDict }) = 63 | Dict.size connDict 64 | -------------------------------------------------------------------------------- /src/Serverless/Conn/Request.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.Request exposing 2 | ( Request, Method(..), Scheme(..) 3 | , url, method, path, queryString 4 | , body 5 | , header, query, endpoint, stage, methodToString 6 | , init, decoder, methodDecoder, schemeDecoder 7 | ) 8 | 9 | {-| Query attributes of the HTTP request. 10 | 11 | 12 | ## Table of Contents 13 | 14 | - [Request Types](#request-types) 15 | - [Routing](#routing) 16 | - [Body](#body) 17 | - [Other Attributes](#other-attributes) 18 | 19 | 20 | ## Request Types 21 | 22 | @docs Request, Method, Scheme 23 | 24 | 25 | ## Routing 26 | 27 | These attributes are typically involved in routing requests. See the 28 | [Routing Demo](https://github.com/ktonon/elm-serverless/blob/master/demo/src/Routing/API.elm) 29 | for an example. 30 | 31 | @docs url, method, path, queryString 32 | 33 | 34 | ## Body 35 | 36 | Functions to access the request body and attempt a cast to a content type. See the 37 | [Forms Demo](https://github.com/ktonon/elm-serverless/blob/master/demo/src/Forms/API.elm) 38 | for an example. 39 | 40 | @docs body 41 | 42 | 43 | ## Other Attributes 44 | 45 | @docs header, query, endpoint, stage, methodToString 46 | 47 | 48 | ## Misc 49 | 50 | These functions are typically not needed when building an application. They are 51 | used internally by the framework. They may be useful when debugging or writing 52 | unit tests. 53 | 54 | @docs init, decoder, methodDecoder, schemeDecoder 55 | 56 | -} 57 | 58 | import Dict exposing (Dict) 59 | import Json.Decode as Decode exposing (Decoder, andThen) 60 | import Json.Decode.Pipeline exposing (hardcoded, required) 61 | import Json.Encode 62 | import Serverless.Conn.Body as Body exposing (Body) 63 | import Serverless.Conn.IpAddress as IpAddress exposing (IpAddress) 64 | import Serverless.Conn.KeyValueList as KeyValueList 65 | 66 | 67 | {-| An HTTP request. 68 | -} 69 | type Request 70 | = Request Model 71 | 72 | 73 | {-| HTTP request method. 74 | 75 | -- to use shorthand notation 76 | import Serverless.Conn.Request exposing (Method(..)) 77 | 78 | -} 79 | type Method 80 | = CONNECT 81 | | DELETE 82 | | GET 83 | | HEAD 84 | | OPTIONS 85 | | PATCH 86 | | POST 87 | | PUT 88 | | TRACE 89 | 90 | 91 | {-| Gets the HTTP Method as a String, like 'GET', 'PUT', etc. 92 | -} 93 | methodToString : Method -> String 94 | methodToString meth = 95 | case meth of 96 | CONNECT -> 97 | "CONNECT" 98 | 99 | DELETE -> 100 | "DELETE" 101 | 102 | GET -> 103 | "GET" 104 | 105 | HEAD -> 106 | "HEAD" 107 | 108 | OPTIONS -> 109 | "OPTIONS" 110 | 111 | PATCH -> 112 | "PATCH" 113 | 114 | POST -> 115 | "POST" 116 | 117 | PUT -> 118 | "PUT" 119 | 120 | TRACE -> 121 | "TRACE" 122 | 123 | 124 | {-| Request scheme (a.k.a. protocol). 125 | 126 | -- to use shorthand notation 127 | import Serverless.Conn.Request exposing (Scheme(..)) 128 | 129 | -} 130 | type Scheme 131 | = Http 132 | | Https 133 | 134 | 135 | type alias Model = 136 | { body : Body 137 | , headers : Dict String String 138 | , host : String 139 | , method : Method 140 | , path : String 141 | , port_ : Int 142 | , remoteIp : IpAddress 143 | , scheme : Scheme 144 | , stage : String 145 | , queryParams : Dict String String 146 | , queryString : String 147 | , url : String 148 | } 149 | 150 | 151 | 152 | -- CONSTRUCTORS 153 | 154 | 155 | {-| Initialize an empty Request. 156 | 157 | Exposed for unit testing. Incoming connections initialize requests using 158 | JSON decoders. 159 | 160 | -} 161 | init : Request 162 | init = 163 | Request 164 | (Model 165 | Body.empty 166 | Dict.empty 167 | "" 168 | GET 169 | "/" 170 | 80 171 | IpAddress.loopback 172 | Http 173 | "test" 174 | Dict.empty 175 | "" 176 | "http://:80/" 177 | ) 178 | 179 | 180 | 181 | -- GETTERS 182 | 183 | 184 | {-| Request body. 185 | -} 186 | body : Request -> Body 187 | body (Request request) = 188 | request.body 189 | 190 | 191 | {-| Describes the server endpoint to which the request was made. 192 | 193 | ( scheme, host, port_ ) = 194 | Request.endpoint req 195 | 196 | - `scheme` is either `Request.Http` or `Request.Https` 197 | - `host` is the hostname as taken from the `"host"` request header 198 | - `port_` is the port, for example `80` or `443` 199 | 200 | -} 201 | endpoint : Request -> ( Scheme, String, Int ) 202 | endpoint (Request req) = 203 | ( req.scheme, req.host, req.port_ ) 204 | 205 | 206 | {-| Get a request header by name. 207 | 208 | Headers are normalized such that the keys are always `lower-case`. 209 | 210 | -} 211 | header : String -> Request -> Maybe String 212 | header key (Request { headers }) = 213 | Dict.get key headers 214 | 215 | 216 | {-| The requested URL if it parsed correctly. 217 | -} 218 | url : Request -> String 219 | url (Request request) = 220 | request.url 221 | 222 | 223 | {-| HTTP request method. 224 | 225 | case Request.method req of 226 | Request.GET -> 227 | -- handle get... 228 | 229 | Request.POST -> 230 | -- handle post... 231 | 232 | _ -> 233 | -- method not supported... 234 | 235 | -} 236 | method : Request -> Method 237 | method (Request request) = 238 | request.method 239 | 240 | 241 | {-| Request path. 242 | 243 | While you can access this attribute directly, it is better to provide a 244 | `parseRoute` function to the framework. 245 | 246 | -} 247 | path : Request -> String 248 | path (Request request) = 249 | request.path 250 | 251 | 252 | {-| Get a query argument by name. 253 | -} 254 | query : String -> Request -> Maybe String 255 | query name (Request { queryParams }) = 256 | Dict.get name queryParams 257 | 258 | 259 | {-| The original query string. 260 | 261 | While you can access this attribute directly, it is better to provide a 262 | `parseRoute` function to the framework. 263 | 264 | -} 265 | queryString : Request -> String 266 | queryString (Request request) = 267 | request.queryString 268 | 269 | 270 | {-| IP address of the requesting entity. 271 | -} 272 | remoteIp : Request -> IpAddress 273 | remoteIp (Request request) = 274 | request.remoteIp 275 | 276 | 277 | {-| Serverless deployment stage. 278 | 279 | See 280 | 281 | -} 282 | stage : Request -> String 283 | stage (Request request) = 284 | request.stage 285 | 286 | 287 | 288 | -- JSON 289 | 290 | 291 | {-| JSON decoder for HTTP requests. 292 | -} 293 | decoder : Decoder Request 294 | decoder = 295 | Decode.succeed HeadersOnly 296 | |> required "headers" (KeyValueList.decoder |> Decode.map Dict.fromList) 297 | |> andThen (Decode.map Request << modelDecoder) 298 | 299 | 300 | type alias HeadersOnly = 301 | { headers : Dict String String 302 | } 303 | 304 | 305 | schemeToString : Scheme -> String 306 | schemeToString scheme = 307 | case scheme of 308 | Http -> 309 | "http:" 310 | 311 | Https -> 312 | "https:" 313 | 314 | 315 | modelDecoder : HeadersOnly -> Decoder Model 316 | modelDecoder { headers } = 317 | Decode.succeed 318 | (\bodyVal headersVal hostVal methodVal pathVal portVal remoteIpVal schemeVal stageVal queryParamsVal queryStringVal -> 319 | { body = bodyVal 320 | , headers = headersVal 321 | , host = hostVal 322 | , method = methodVal 323 | , path = pathVal 324 | , port_ = portVal 325 | , remoteIp = remoteIpVal 326 | , scheme = schemeVal 327 | , stage = stageVal 328 | , queryParams = queryParamsVal 329 | , queryString = queryStringVal 330 | , url = 331 | schemeToString schemeVal 332 | ++ "//" 333 | ++ hostVal 334 | ++ ":" 335 | ++ String.fromInt portVal 336 | ++ pathVal 337 | ++ queryStringVal 338 | } 339 | ) 340 | |> required "body" (Body.decoder <| Dict.get "content-type" headers) 341 | |> hardcoded headers 342 | |> required "host" Decode.string 343 | |> required "method" methodDecoder 344 | |> required "path" Decode.string 345 | |> required "port" Decode.int 346 | |> required "remoteIp" IpAddress.decoder 347 | |> required "scheme" schemeDecoder 348 | |> required "stage" Decode.string 349 | |> required "queryParams" (KeyValueList.decoder |> Decode.map Dict.fromList) 350 | |> required "queryString" Decode.string 351 | 352 | 353 | {-| JSON decoder for the HTTP request method. 354 | -} 355 | methodDecoder : Decoder Method 356 | methodDecoder = 357 | Decode.string 358 | |> Decode.andThen 359 | (\w -> 360 | case w |> String.toLower of 361 | "connect" -> 362 | Decode.succeed CONNECT 363 | 364 | "delete" -> 365 | Decode.succeed DELETE 366 | 367 | "get" -> 368 | Decode.succeed GET 369 | 370 | "head" -> 371 | Decode.succeed HEAD 372 | 373 | "options" -> 374 | Decode.succeed OPTIONS 375 | 376 | "patch" -> 377 | Decode.succeed PATCH 378 | 379 | "post" -> 380 | Decode.succeed POST 381 | 382 | "put" -> 383 | Decode.succeed PUT 384 | 385 | "trace" -> 386 | Decode.succeed TRACE 387 | 388 | _ -> 389 | Decode.fail ("Unsupported method: " ++ w) 390 | ) 391 | 392 | 393 | {-| JSON decoder for the request scheme (a.k.a. protocol) 394 | -} 395 | schemeDecoder : Decoder Scheme 396 | schemeDecoder = 397 | Decode.string 398 | |> Decode.andThen 399 | (\w -> 400 | case w |> String.toLower of 401 | "http" -> 402 | Decode.succeed Http 403 | 404 | "https" -> 405 | Decode.succeed Https 406 | 407 | _ -> 408 | Decode.fail ("Unsupported scheme: " ++ w) 409 | ) 410 | -------------------------------------------------------------------------------- /src/Serverless/Conn/Response.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.Response exposing 2 | ( Response, Status 3 | , addHeader, setBody, updateBody, setStatus 4 | , init, encode 5 | ) 6 | 7 | {-| Query and update the HTTP response. 8 | 9 | @docs Response, Status 10 | 11 | 12 | ## Updating 13 | 14 | @docs addHeader, setBody, updateBody, setStatus 15 | 16 | 17 | ## Misc 18 | 19 | These functions are typically not needed when building an application. They are 20 | used internally by the framework. They may be useful when debugging or writing 21 | unit tests. 22 | 23 | @docs init, encode 24 | 25 | -} 26 | 27 | import Json.Encode as Encode 28 | import Serverless.Conn.Body as Body exposing (Body, text) 29 | import Serverless.Conn.Charset as Charset exposing (Charset) 30 | import Serverless.Conn.KeyValueList as KeyValueList 31 | 32 | 33 | {-| An HTTP response. 34 | -} 35 | type Response 36 | = Response Model 37 | 38 | 39 | type alias Model = 40 | { body : Body 41 | , charset : Charset 42 | , headers : List ( String, String ) 43 | , status : Status 44 | } 45 | 46 | 47 | {-| An HTTP status code. 48 | -} 49 | type alias Status = 50 | Int 51 | 52 | 53 | 54 | -- UPDATING 55 | 56 | 57 | {-| Set a response header. 58 | 59 | If you set the same response header more than once, the second value will 60 | override the first. 61 | 62 | -} 63 | addHeader : ( String, String ) -> Response -> Response 64 | addHeader ( key, value ) (Response res) = 65 | Response 66 | { res 67 | | headers = 68 | ( key |> String.toLower, value ) 69 | :: res.headers 70 | } 71 | 72 | 73 | {-| Set the response body. 74 | -} 75 | setBody : Body -> Response -> Response 76 | setBody body (Response res) = 77 | Response { res | body = body } 78 | 79 | 80 | {-| Updates the response body. 81 | -} 82 | updateBody : (Body -> Body) -> Response -> Response 83 | updateBody updater (Response res) = 84 | Response { res | body = updater res.body } 85 | 86 | 87 | setCharset : Charset -> Response -> Response 88 | setCharset value (Response res) = 89 | Response { res | charset = value } 90 | 91 | 92 | {-| Set the response HTTP status code. 93 | -} 94 | setStatus : Status -> Response -> Response 95 | setStatus value (Response res) = 96 | Response { res | status = value } 97 | 98 | 99 | 100 | -- MISC 101 | 102 | 103 | {-| A response with an empty body and invalid status. 104 | -} 105 | init : Response 106 | init = 107 | Response 108 | (Model 109 | Body.empty 110 | Charset.utf8 111 | [ ( "cache-control", "max-age=0, private, must-revalidate" ) ] 112 | 200 113 | ) 114 | 115 | 116 | {-| JSON encode an HTTP response. 117 | -} 118 | encode : Response -> Encode.Value 119 | encode (Response res) = 120 | Encode.object 121 | [ ( "body", Body.encode res.body ) 122 | , ( "headers" 123 | , res.headers 124 | ++ [ ( "content-type", contentType res ) ] 125 | |> KeyValueList.encode 126 | ) 127 | , ( "statusCode", Encode.int res.status ) 128 | , ( "isBase64Encoded", res.body |> Body.isBase64Encoded |> Encode.bool ) 129 | ] 130 | 131 | 132 | contentType : Model -> String 133 | contentType { body, charset } = 134 | Body.contentType body 135 | ++ "; charset=" 136 | ++ Charset.toString charset 137 | -------------------------------------------------------------------------------- /src/Serverless/Cors.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Cors exposing 2 | ( Config, Reflectable(..) 3 | , configDecoder, methodsDecoder, reflectableDecoder 4 | , fromConfig, allowOrigin, exposeHeaders, maxAge, allowCredentials, allowMethods, allowHeaders 5 | , cors 6 | ) 7 | 8 | {-| CORS Middleware for elm-serverless. 9 | 10 | 11 | ## Types 12 | 13 | @docs Config, Reflectable 14 | 15 | 16 | ## Decoders 17 | 18 | @docs configDecoder, methodsDecoder, reflectableDecoder 19 | 20 | 21 | ## Middleware 22 | 23 | @docs fromConfig, allowOrigin, exposeHeaders, maxAge, allowCredentials, allowMethods, allowHeaders 24 | 25 | 26 | ## Deprecated 27 | 28 | @docs cors 29 | 30 | -} 31 | 32 | import Json.Decode exposing (Decoder, andThen, bool, fail, int, list, oneOf, string, succeed) 33 | import Json.Decode.Pipeline exposing (optional) 34 | import Serverless.Conn as Conn exposing (..) 35 | import Serverless.Conn.Request exposing (Method(..), methodToString) 36 | import Serverless.Conn.Response exposing (addHeader) 37 | 38 | 39 | 40 | -- TYPES 41 | 42 | 43 | {-| Specify all CORS configuration in one record. 44 | -} 45 | type alias Config = 46 | { origin : Reflectable (List String) 47 | , expose : List String 48 | , maxAge : Int 49 | , credentials : Bool 50 | , methods : List Method 51 | , headers : Reflectable (List String) 52 | } 53 | 54 | 55 | {-| A reflectable header value. 56 | 57 | A reflectable value can either be 58 | 59 | - `ReflectRequest` derive the headers from the request 60 | - `Exactly` set to a specific value 61 | 62 | -} 63 | type Reflectable a 64 | = ReflectRequest 65 | | Exactly a 66 | 67 | 68 | 69 | -- DECODERS 70 | 71 | 72 | {-| Decode CORS configuration from JSON. 73 | -} 74 | configDecoder : Decoder Config 75 | configDecoder = 76 | succeed Config 77 | |> optional "origin" reflectableDecoder (Exactly []) 78 | |> optional "expose" stringListDecoder [] 79 | |> optional "maxAge" maxAgeDecoder 0 80 | |> optional "credentials" truthyDecoder False 81 | |> optional "methods" methodsDecoder [] 82 | |> optional "headers" reflectableDecoder (Exactly []) 83 | 84 | 85 | {-| Decode a reflectable value from JSON. 86 | 87 | - `"*"` decodes to `ReflectRequest` 88 | - `"foo,bar"` or `["foo", "bar"]` decodes to `Exactly ["foo", "bar"]` 89 | 90 | -} 91 | reflectableDecoder : Decoder (Reflectable (List String)) 92 | reflectableDecoder = 93 | stringListDecoder 94 | |> andThen 95 | (\strings -> 96 | if strings == [ "*" ] then 97 | succeed ReflectRequest 98 | 99 | else 100 | strings |> Exactly |> succeed 101 | ) 102 | 103 | 104 | maxAgeDecoder : Decoder Int 105 | maxAgeDecoder = 106 | oneOf 107 | [ int |> andThen positiveIntDecoder 108 | , string 109 | |> andThen 110 | (\w -> 111 | case String.toInt w of 112 | Just val -> 113 | positiveIntDecoder val 114 | 115 | Nothing -> 116 | fail ("Decoding maxAge: not a positive integer " ++ w) 117 | ) 118 | ] 119 | 120 | 121 | positiveIntDecoder : Int -> Decoder Int 122 | positiveIntDecoder val = 123 | if val < 0 then 124 | fail "negative value when zero or positive was expected" 125 | 126 | else 127 | succeed val 128 | 129 | 130 | truthyDecoder : Decoder Bool 131 | truthyDecoder = 132 | oneOf 133 | [ bool 134 | , int |> andThen (\val -> val /= 0 |> succeed) 135 | , string |> andThen (\val -> not (String.isEmpty val) |> succeed) 136 | ] 137 | 138 | 139 | stringListDecoder : Decoder (List String) 140 | stringListDecoder = 141 | oneOf 142 | [ list string 143 | , string |> andThen (String.split "," >> succeed) 144 | ] 145 | 146 | 147 | {-| Case-insensitive decode a list of HTTP methods. 148 | -} 149 | methodsDecoder : Decoder (List Method) 150 | methodsDecoder = 151 | stringListDecoder 152 | |> andThen 153 | (\strings -> 154 | case 155 | strings 156 | |> List.map 157 | (\w -> 158 | case w |> String.toLower of 159 | "get" -> 160 | Just GET 161 | 162 | "post" -> 163 | Just POST 164 | 165 | "put" -> 166 | Just PUT 167 | 168 | "delete" -> 169 | Just DELETE 170 | 171 | "options" -> 172 | Just OPTIONS 173 | 174 | _ -> 175 | Nothing 176 | ) 177 | |> maybeList 178 | of 179 | Just methods -> 180 | succeed methods 181 | 182 | Nothing -> 183 | fail 184 | ("Invalid CORS methods: " 185 | ++ (strings |> String.join ",") 186 | ) 187 | ) 188 | 189 | 190 | 191 | -- MIDDLEWARE 192 | 193 | 194 | {-| Set CORS headers according to a configuration record. 195 | 196 | This function is best used when the configuration is provided externally and 197 | decoded using `configDecoder`. For example, npm rc and AWS Lambda environment 198 | variables can be used as the source of CORS configuration. 199 | 200 | -} 201 | fromConfig : 202 | (config -> Config) 203 | -> Conn config model route msg 204 | -> Conn config model route msg 205 | fromConfig extract conn = 206 | cors (conn |> Conn.config |> extract) conn 207 | 208 | 209 | {-| Deprecated. Use fromConfig. 210 | -} 211 | cors : 212 | Config 213 | -> Conn config model route msg 214 | -> Conn config model route msg 215 | cors config = 216 | allowOrigin config.origin 217 | >> exposeHeaders config.expose 218 | >> maxAge config.maxAge 219 | >> allowCredentials config.credentials 220 | >> allowMethods config.methods 221 | >> allowHeaders config.headers 222 | 223 | 224 | {-| Sets `access-control-allow-origin`. 225 | 226 | `ReflectRequest` will reflect the request `origin` header, or if absent, will 227 | just be set to `*` 228 | 229 | -} 230 | allowOrigin : 231 | Reflectable (List String) 232 | -> Conn config model route msg 233 | -> Conn config model route msg 234 | allowOrigin origin conn = 235 | case origin of 236 | ReflectRequest -> 237 | updateResponse 238 | (addHeader 239 | ( "access-control-allow-origin" 240 | , header "origin" conn 241 | |> Maybe.withDefault "*" 242 | ) 243 | ) 244 | conn 245 | 246 | Exactly origins -> 247 | if origins |> List.isEmpty then 248 | conn 249 | 250 | else 251 | updateResponse 252 | (addHeader 253 | ( "access-control-allow-origin" 254 | , origins |> String.join "," 255 | ) 256 | ) 257 | conn 258 | 259 | 260 | {-| Sets `access-control-expose-headers`. 261 | -} 262 | exposeHeaders : 263 | List String 264 | -> Conn config model route msg 265 | -> Conn config model route msg 266 | exposeHeaders headers conn = 267 | if headers |> List.isEmpty then 268 | conn 269 | 270 | else 271 | updateResponse 272 | (addHeader 273 | ( "access-control-expose-headers" 274 | , headers |> String.join "," 275 | ) 276 | ) 277 | conn 278 | 279 | 280 | {-| Sets `access-control-max-age`. 281 | 282 | If the value is not positive, the header will not be set. 283 | 284 | -} 285 | maxAge : 286 | Int 287 | -> Conn config model route msg 288 | -> Conn config model route msg 289 | maxAge age conn = 290 | if age > 0 then 291 | updateResponse 292 | (addHeader 293 | ( "access-control-max-age" 294 | , age |> String.fromInt 295 | ) 296 | ) 297 | conn 298 | 299 | else 300 | conn 301 | 302 | 303 | {-| Sets `access-control-allow-credentials`. 304 | 305 | Only sets the header if the value is `True`. 306 | 307 | -} 308 | allowCredentials : 309 | Bool 310 | -> Conn config model route msg 311 | -> Conn config model route msg 312 | allowCredentials allow conn = 313 | if allow then 314 | updateResponse 315 | (addHeader ( "access-control-allow-credentials", "true" )) 316 | conn 317 | 318 | else 319 | conn 320 | 321 | 322 | {-| Sets `access-control-allow-methods`. 323 | -} 324 | allowMethods : 325 | List Method 326 | -> Conn config model route msg 327 | -> Conn config model route msg 328 | allowMethods methods conn = 329 | if methods |> List.isEmpty then 330 | conn 331 | 332 | else 333 | updateResponse 334 | (addHeader 335 | ( "access-control-allow-methods" 336 | , methods |> List.map methodToString |> String.join "," 337 | ) 338 | ) 339 | conn 340 | 341 | 342 | {-| Sets `access-control-allow-headers`. 343 | 344 | `ReflectRequest` will reflect the request `access-control-request-headers` headers 345 | or if absent, it will not set the header at all. 346 | 347 | -} 348 | allowHeaders : 349 | Reflectable (List String) 350 | -> Conn config model route msg 351 | -> Conn config model route msg 352 | allowHeaders headers conn = 353 | case headers of 354 | ReflectRequest -> 355 | case 356 | header "access-control-request-headers" conn 357 | of 358 | Just requestHeaders -> 359 | updateResponse 360 | (addHeader 361 | ( "access-control-allow-headers" 362 | , requestHeaders 363 | ) 364 | ) 365 | conn 366 | 367 | Nothing -> 368 | conn 369 | 370 | Exactly h -> 371 | if List.isEmpty h then 372 | conn 373 | 374 | else 375 | updateResponse 376 | (addHeader 377 | ( "access-control-allow-headers" 378 | , h |> String.join "," 379 | ) 380 | ) 381 | conn 382 | 383 | 384 | maybeList : List (Maybe a) -> Maybe (List a) 385 | maybeList list = 386 | case list |> List.take 1 of 387 | [ Just value ] -> 388 | list 389 | |> List.drop 1 390 | |> maybeList 391 | |> Maybe.map ((::) value) 392 | 393 | [] -> 394 | Just [] 395 | 396 | _ -> 397 | Nothing 398 | -------------------------------------------------------------------------------- /src/Serverless/Plug.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Plug exposing 2 | ( Plug 3 | , pipeline, plug, nest 4 | , apply 5 | , size 6 | ) 7 | 8 | {-| A **pipeline** is a sequence of functions which transform the connection, 9 | optionally sending back an HTTP response at each step. We use the term **plug** 10 | to mean a single function that is part of the pipeline. But a pipeline is also 11 | just a plug and so pipelines can be composed from other pipelines. 12 | 13 | Examples below assume the following imports: 14 | 15 | import Serverless.Conn exposing (updateResponse) 16 | import Serverless.Conn.Body exposing (text) 17 | import Serverless.Conn.Response exposing (addHeader, setBody, setStatus) 18 | 19 | @docs Plug 20 | 21 | 22 | ## Building Pipelines 23 | 24 | Use these functions to build your pipelines. 25 | 26 | @docs pipeline, plug, nest 27 | 28 | 29 | ## Applying Pipelines 30 | 31 | @docs apply 32 | 33 | 34 | ## Misc 35 | 36 | These functions are typically not needed when building an application. They are 37 | used internally by the framework. They are useful when debugging or writing unit 38 | tests. 39 | 40 | @docs size 41 | 42 | -} 43 | 44 | import Serverless.Conn as Conn exposing (Conn) 45 | 46 | 47 | {-| Represents a pipeline or section of a pipeline. 48 | -} 49 | type Plug config model route msg 50 | = Simple (Conn config model route msg -> Conn config model route msg) 51 | | Pipeline (List (Plug config model route msg)) 52 | 53 | 54 | 55 | -- CONSTRUCTORS 56 | 57 | 58 | {-| Begins a pipeline. 59 | 60 | Build the pipeline by chaining plugs with plug, loop, fork, and nest. 61 | 62 | size pipeline 63 | --> 0 64 | 65 | -} 66 | pipeline : Plug config model route msg 67 | pipeline = 68 | Pipeline [] 69 | 70 | 71 | {-| Extends the pipeline with a plug. 72 | 73 | This is the most general of the pipeline building functions. Since it just 74 | accepts a plug, and since a plug can be a pipeline, it can be used to extend a 75 | pipeline with a group of plugs. 76 | 77 | pipeline 78 | |> nest (pipeline 79 | |> plug (updateResponse <| addHeader ( "key", "value" )) 80 | -- ... 81 | ) 82 | |> plug (updateResponse <| setStatus 200) 83 | |> size 84 | --> 2 85 | 86 | -} 87 | nest : 88 | Plug config model route msg 89 | -> Plug config model route msg 90 | -> Plug config model route msg 91 | nest a b = 92 | case ( a, b ) of 93 | ( Pipeline begin, Pipeline end ) -> 94 | Pipeline <| List.append end begin 95 | 96 | ( Pipeline begin, _ ) -> 97 | Pipeline <| b :: begin 98 | 99 | ( _, Pipeline end ) -> 100 | Pipeline <| List.append end [ a ] 101 | 102 | _ -> 103 | Pipeline [ b, a ] 104 | 105 | 106 | {-| Extend the pipeline with a simple plug. 107 | 108 | A plug just transforms the connection. For example, 109 | 110 | pipeline 111 | |> plug (updateResponse <| setBody <| text "Ok" ) 112 | |> plug (updateResponse <| setStatus 200) 113 | |> size 114 | --> 2 115 | 116 | -} 117 | plug : 118 | (Conn config model route msg -> Conn config model route msg) 119 | -> Plug config model route msg 120 | -> Plug config model route msg 121 | plug func = 122 | nest (Simple func) 123 | 124 | 125 | {-| Basic pipeline update function. 126 | -} 127 | apply : 128 | Plug config model route msg 129 | -> Conn config model route msg 130 | -> Conn config model route msg 131 | apply givenPlug conn = 132 | case ( Conn.unsent conn, givenPlug ) of 133 | ( Nothing, _ ) -> 134 | conn 135 | 136 | ( _, Simple transform ) -> 137 | transform conn 138 | 139 | ( _, Pipeline plugs ) -> 140 | List.foldl 141 | apply 142 | conn 143 | plugs 144 | 145 | 146 | 147 | -- MISC 148 | 149 | 150 | {-| The number of plugs in a pipeline 151 | -} 152 | size : Plug config model route msg -> Int 153 | size givenPlug = 154 | case givenPlug of 155 | Simple _ -> 156 | 1 157 | 158 | Pipeline plugs -> 159 | List.length plugs 160 | -------------------------------------------------------------------------------- /test/bridge/bridge-spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | const sinon = require('sinon'); 3 | 4 | const { httpApi } = require('../../src-bridge'); 5 | const spyLogger = require('./spy-logger'); 6 | 7 | const requestPort = 'requestPort'; 8 | const responsePort = 'responsePort'; 9 | const makeWorkerApp = () => ({ 10 | init: sinon.stub().returns({ 11 | ports: { 12 | requestPort: { send: sinon.spy() }, 13 | responsePort: { subscribe: sinon.spy() }, 14 | }, 15 | }) 16 | }); 17 | 18 | describe('elmServerless', () => { 19 | describe('.httpApi({ app, requestPort, responsePort })', () => { 20 | it('is a function', () => { 21 | should(httpApi).be.a.Function(); 22 | }); 23 | 24 | it('works with valid app, requestPort, and responsePort', () => { 25 | (() => httpApi({ app: makeWorkerApp().init(), requestPort, responsePort })) 26 | .should.not.throw(); 27 | }); 28 | 29 | it('passes config to the handler.init function', () => { 30 | const config = { some: { app: ['specific', 'configuration'] } }; 31 | const w = makeWorkerApp(); 32 | const h = w.init({ flags: config }); 33 | httpApi({ app: h, requestPort, responsePort }); 34 | w.init.calledWith({ flags: config }).should.be.true(); 35 | }); 36 | 37 | it('subscribes to the responsePort', () => { 38 | const h = makeWorkerApp().init(); 39 | httpApi({ app: h, requestPort, responsePort }); 40 | const subscribe = h.ports.responsePort.subscribe; 41 | subscribe.called.should.be.true(); 42 | const call = subscribe.getCall(0); 43 | const [func] = call.args; 44 | should(func).be.a.Function(); 45 | }); 46 | 47 | it('handles responses', () => { 48 | const h = makeWorkerApp().init(); 49 | const logger = spyLogger(); 50 | httpApi({ app: h, logger, requestPort, responsePort }); 51 | const subscribe = h.ports.responsePort.subscribe; 52 | const responseHandler = subscribe.getCalls()[0].args[0]; 53 | logger.error.getCalls().should.be.empty(); 54 | responseHandler(['id', {}]); 55 | logger.error.getCalls().should.not.be.empty(); 56 | logger.error.getCalls()[0].args.should.deepEqual(['No callback for ID: id']); 57 | }); 58 | 59 | it('returns a request handler', () => { 60 | const h = makeWorkerApp().init(); 61 | const func = httpApi({ app: h, requestPort, responsePort }); 62 | should(func).be.a.Function(); 63 | should(func.name).equal('requestHandler'); 64 | }); 65 | 66 | it('requires an app', () => { 67 | (() => httpApi({ requestPort, responsePort })) 68 | .should.throw(/^handler.init did not return valid Elm app.Got: undefined/); 69 | }); 70 | 71 | it('requires a requestPort', () => { 72 | (() => httpApi({ 73 | app: makeWorkerApp().init(), 74 | requestPort: 'reqPort', 75 | responsePort, 76 | })) 77 | .should.throw(/^No request port named reqPort among: \[requestPort, responsePort\]$/); 78 | }); 79 | 80 | it('requires a valid requestPort', () => { 81 | (() => httpApi({ 82 | app: makeWorkerApp().init(), 83 | requestPort: 'responsePort', 84 | responsePort, 85 | })) 86 | .should.throw(/^Invalid request port/); 87 | }); 88 | 89 | it('requires a responsePort', () => { 90 | (() => httpApi({ 91 | app: makeWorkerApp().init(), 92 | requestPort, 93 | responsePort: 'respPort', 94 | })) 95 | .should.throw(/^No response port named respPort among: \[requestPort, responsePort\]$/); 96 | }); 97 | 98 | it('requires a valid responsePort', () => { 99 | (() => httpApi({ 100 | app: makeWorkerApp().init(), 101 | requestPort, 102 | responsePort: 'requestPort', 103 | })) 104 | .should.throw(/^Invalid response port/); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/bridge/normalize-headers-spec.js: -------------------------------------------------------------------------------- 1 | const norm = require('../../src-bridge/normalize-headers'); 2 | 3 | describe('norm', () => { 4 | it('is a function', () => { 5 | norm.should.be.a.Function(); 6 | }); 7 | 8 | it('converts header keys to lowercase', () => { 9 | norm({ 'Foo-Bar': 'Some Text', Age: '3' }) 10 | .should.deepEqual({ 11 | 'foo-bar': 'Some Text', 12 | age: '3', 13 | }); 14 | }); 15 | 16 | it('converts numbers and bools to strings', () => { 17 | norm({ age: 3, good: true, bad: false }) 18 | .should.deepEqual({ 19 | age: '3', 20 | good: 'true', 21 | bad: 'false', 22 | }); 23 | }); 24 | 25 | it('removes keys with undefined or null values', () => { 26 | norm({ 'is-undef': undefined, 'is-null': null }) 27 | .should.deepEqual({}); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/bridge/pool-spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | const sinon = require('sinon'); 3 | const uuid = require('uuid'); 4 | 5 | const Pool = require('../../src-bridge/pool'); 6 | const spyLogger = require('./spy-logger'); 7 | 8 | const makeCallback = (retVal) => sinon.stub().returns(retVal); 9 | const id = uuid.v4(); 10 | 11 | describe('new Pool()', () => { 12 | describe('put(id, req, callback) and take(id)', () => { 13 | it('are used to associate callbacks with identifiers', () => { 14 | const pool = new Pool(); 15 | pool.put(id, {}, makeCallback('foo')); 16 | const { callback } = pool.take(id); 17 | callback.should.not.throw(); 18 | callback().should.equal('foo'); 19 | }); 20 | }); 21 | 22 | describe('put({ id }, callback)', () => { 23 | it('logs an error if the same id is used twice', () => { 24 | const pool = new Pool({ logger: spyLogger() }); 25 | pool.put(id, {}, makeCallback()); 26 | pool.logger.error.called.should.be.false(); 27 | pool.put(id, {}, makeCallback()); 28 | pool.logger.error.called.should.be.true(); 29 | }); 30 | 31 | it('replaces the callback if the same id is used twice', () => { 32 | const pool = new Pool({ logger: spyLogger() }); 33 | pool.put(id, {}, makeCallback('foo')); 34 | pool.put(id, {}, makeCallback('bar')); 35 | pool.take(id).callback().should.equal('bar'); 36 | }); 37 | 38 | it('throws an error if the callback is not a function', () => { 39 | const pool = new Pool(); 40 | (() => pool.put({ id }, 'call-me-baby')).should.throw(); 41 | }); 42 | }); 43 | 44 | describe('take(id)', () => { 45 | it('logs an error if no callback is associated for the id', () => { 46 | const pool = new Pool({ logger: spyLogger() }); 47 | pool.logger.error.called.should.be.false(); 48 | pool.take(uuid.v4()); 49 | pool.logger.error.called.should.be.true(); 50 | }); 51 | 52 | it('removes the callback from the pool', () => { 53 | const pool = new Pool({ logger: spyLogger() }); 54 | pool.put(id, {}, makeCallback('foo')); 55 | pool.take(id).callback().should.equal('foo'); 56 | pool.logger.error.called.should.be.false(); 57 | should(pool.take(id).callback).be.undefined(); 58 | pool.logger.error.called.should.be.true(); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/bridge/request-handler-spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | const sinon = require('sinon'); 3 | const uuid = require('uuid'); 4 | 5 | const Pool = require('../../src-bridge/pool'); 6 | const requestHandler = require('../../src-bridge/request-handler'); 7 | const spyLogger = require('./spy-logger'); 8 | 9 | const makeHandler = () => { 10 | const config = { 11 | pool: new Pool({ logger: spyLogger() }), 12 | requestPort: { send: sinon.spy() }, 13 | logger: spyLogger() 14 | }; 15 | return Object.assign(requestHandler(config), config); 16 | }; 17 | 18 | const context = {}; 19 | 20 | const id = uuid.v4(); 21 | 22 | const connections = pool => 23 | Object.keys(pool.connections).map(key => pool.connections[key]); 24 | 25 | describe('requestHandler({ pool })', () => { 26 | it('is a function', () => { 27 | should(makeHandler()).be.a.Function(); 28 | }); 29 | 30 | it('creates a request object and puts it into the connection pool with the callback', () => { 31 | const h = makeHandler(); 32 | connections(h.pool).should.be.empty(); 33 | const callback = sinon.spy(); 34 | h({}, context, callback); 35 | const [conn] = connections(h.pool); 36 | conn.should.be.an.Object(); 37 | should(conn.callback).equal(callback); 38 | should(conn.req).be.an.Object().with.property('host'); 39 | }); 40 | 41 | it('does not call the callback', () => { 42 | const h = makeHandler(); 43 | h({ id }, context, sinon.spy()); 44 | const { callback } = h.pool.take(id); 45 | callback.called.should.be.false(); 46 | }); 47 | 48 | it('does send the request into elm via the request port', () => { 49 | const h = makeHandler(); 50 | h.requestPort.send.called.should.be.false(); 51 | h({ id }, context, sinon.spy()); 52 | const { req } = h.pool.take(id); 53 | h.requestPort.send.calledWith([id, req]).should.be.true(); 54 | }); 55 | 56 | it('leaves string bodies unchanged', () => { 57 | const h = makeHandler(); 58 | const body = 'this body\'s content is a string!'; 59 | h({ id, body }, context, sinon.spy()); 60 | h.pool.take(id).req.body.should.equal(body); 61 | }); 62 | 63 | it('JSON stringifies other types of bodies', () => { 64 | const h = makeHandler(); 65 | const body = { some: { thing: [4, 'json'] } }; 66 | h({ id, body }, context, sinon.spy()); 67 | h.pool.take(id).req.body.should.equal('{"some":{"thing":[4,"json"]}}'); 68 | }); 69 | 70 | it('creates a unique id for each request', () => { 71 | const h = makeHandler(); 72 | const n = 100; 73 | [...Array(n)].forEach(() => h({}, context, sinon.spy())); 74 | const ids = new Set(); 75 | connections(h.pool).forEach(conn => ids.add(conn.id)); 76 | ids.size.should.equal(n); 77 | }); 78 | 79 | it('info logs the request', () => { 80 | const h = makeHandler(); 81 | h.logger.info.called.should.be.false(); 82 | h({}, context, sinon.spy()); 83 | h.logger.info.called.should.be.true(); 84 | }); 85 | 86 | it('can get host and port from the "Host" header', () => { 87 | const h = makeHandler(); 88 | h({ id, headers: { Host: 'localhost:3000' } }, context, sinon.spy()); 89 | const { host, port } = h.pool.take(id).req; 90 | host.should.equal('localhost'); 91 | port.should.equal(3000); 92 | }); 93 | 94 | it('can get host and port from the "host" header', () => { 95 | const h = makeHandler(); 96 | h({ id, headers: { host: 'localhost:3000' } }, context, sinon.spy()); 97 | const { host, port } = h.pool.take(id).req; 98 | host.should.equal('localhost'); 99 | port.should.equal(3000); 100 | }); 101 | 102 | it('can get method from the method arg', () => { 103 | const h = makeHandler(); 104 | const method = 'PUT'; 105 | h({ id, method }, context, sinon.spy()); 106 | h.pool.take(id).req.method.should.equal(method); 107 | }); 108 | 109 | it('can get method from the httpMethod arg', () => { 110 | const h = makeHandler(); 111 | const httpMethod = 'POST'; 112 | h({ id, httpMethod }, context, sinon.spy()); 113 | h.pool.take(id).req.method.should.equal(httpMethod); 114 | }); 115 | 116 | it('normalizes the headers', () => { 117 | const h = makeHandler(); 118 | const headers = { 119 | 'Upper-Case': false, 120 | 'lower-case': 'Things', 121 | 'X-strange': 3, 122 | 'Find-Me': null, 123 | }; 124 | h({ id, headers }, context, sinon.spy()); 125 | h.pool.take(id).req.headers.should.deepEqual({ 126 | 'upper-case': 'false', 127 | 'lower-case': 'Things', 128 | 'x-strange': '3', 129 | }); 130 | }); 131 | 132 | it('can get path from pathParameters[0]', () => { 133 | const h = makeHandler(); 134 | const pathParameters = ['foo/bar/car']; 135 | h({ id, pathParameters }, context, sinon.spy()); 136 | h.pool.take(id).req.path.should.equal('/foo/bar/car'); 137 | }); 138 | 139 | it('can get path from pathParameters.proxy', () => { 140 | const h = makeHandler(); 141 | const pathParameters = { proxy: 'not/very/far' }; 142 | h({ id, pathParameters }, context, sinon.spy()); 143 | h.pool.take(id).req.path.should.equal('/not/very/far'); 144 | }); 145 | 146 | it('defaults the path to /', () => { 147 | const h = makeHandler(); 148 | h({ id }, context, sinon.spy()); 149 | h.pool.take(id).req.path.should.equal('/'); 150 | }); 151 | 152 | it('the "X-Forwarded-Port" header takes precedence', () => { 153 | const h = makeHandler(); 154 | h({ 155 | id, 156 | headers: { host: 'localhost:3000', 'X-Forwarded-Port': '3032' }, 157 | }, context, sinon.spy()); 158 | const { host, port } = h.pool.take(id).req; 159 | host.should.equal('localhost'); 160 | port.should.equal(3032); 161 | }); 162 | 163 | it('sets the remoteIp', () => { 164 | const h = makeHandler(); 165 | const sourceIp = '192.168.1.1'; 166 | h({ 167 | id, 168 | requestContext: { identity: { sourceIp } }, 169 | }, context, sinon.spy()); 170 | h.pool.take(id).req.remoteIp.should.equal(sourceIp); 171 | }); 172 | 173 | it('defaults the remoteIp to 127.0.0.1', () => { 174 | const h = makeHandler(); 175 | h({ id }, context, sinon.spy()); 176 | h.pool.take(id).req.remoteIp.should.equal('127.0.0.1'); 177 | }); 178 | 179 | it('sets the stage', () => { 180 | const h = makeHandler(); 181 | const stage = 'production'; 182 | h({ 183 | id, 184 | requestContext: { stage }, 185 | }, context, sinon.spy()); 186 | h.pool.take(id).req.stage.should.equal(stage); 187 | }); 188 | 189 | it('defaults the stage to local', () => { 190 | const h = makeHandler(); 191 | h({ id }, context, sinon.spy()); 192 | h.pool.take(id).req.stage.should.equal('local'); 193 | }); 194 | 195 | it('sets queryParams', () => { 196 | const h = makeHandler(); 197 | const queryStringParameters = { 198 | foo: 'bar', 199 | car: 'far', 200 | }; 201 | h({ id, queryStringParameters }, context, sinon.spy()); 202 | h.pool.take(id).req.queryParams.should.equal(queryStringParameters); 203 | }); 204 | 205 | it('defaults queryParams to {}', () => { 206 | const h = makeHandler(); 207 | h({ id }, context, sinon.spy()); 208 | h.pool.take(id).req.queryParams.should.deepEqual({}); 209 | }); 210 | 211 | it('sets the scheme', () => { 212 | const h = makeHandler(); 213 | const scheme = 'https'; 214 | h({ 215 | id, 216 | headers: { 'X-Forwarded-Proto': scheme }, 217 | }, context, sinon.spy()); 218 | h.pool.take(id).req.scheme.should.equal(scheme); 219 | }); 220 | 221 | it('defaults the scheme to http', () => { 222 | const h = makeHandler(); 223 | h({ id }, context, sinon.spy()); 224 | h.pool.take(id).req.scheme.should.equal('http'); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /test/bridge/response-handler-spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | const sinon = require('sinon'); 3 | const uuid = require('uuid'); 4 | 5 | const Pool = require('../../src-bridge/pool'); 6 | const responseHandler = require('../../src-bridge/response-handler'); 7 | const spyLogger = require('./spy-logger'); 8 | 9 | const id = uuid.v4(); 10 | 11 | const makeHandler = () => { 12 | const config = { pool: new Pool({ logger: spyLogger() }), logger: spyLogger() }; 13 | return Object.assign(responseHandler(config), config); 14 | }; 15 | 16 | describe('responseHandler({ pool })', () => { 17 | it('is a function', () => { 18 | should(makeHandler()).be.a.Function(); 19 | }); 20 | 21 | it('info logs the response', () => { 22 | const h = makeHandler(); 23 | h.pool.put(id, {}, () => null); 24 | h.logger.info.called.should.be.false(); 25 | h(id, {}); 26 | h.logger.error.called.should.be.false(); 27 | h.logger.info.called.should.be.true(); 28 | }); 29 | 30 | it('logs an error if the callback is missing', () => { 31 | const h = makeHandler(); 32 | h.logger.error.called.should.be.false(); 33 | h(id, {}); 34 | h.logger.error.called.should.be.true(); 35 | }); 36 | 37 | it('calls the callback', () => { 38 | const h = makeHandler(); 39 | const cb = sinon.spy(); 40 | h.pool.put(id, {}, cb); 41 | cb.called.should.be.false(); 42 | h(id, {}); 43 | cb.called.should.be.true(); 44 | }); 45 | 46 | it('calls the callback with reasonable defaults', () => { 47 | const h = makeHandler(); 48 | const cb = sinon.spy(); 49 | h.pool.put(id, {}, cb); 50 | h(id, {}); 51 | cb.calledWith(null, { 52 | statusCode: 500, 53 | body: `${responseHandler.missingStatusCodeBody}: undefined`, 54 | headers: responseHandler.defaultHeaders(''), 55 | isBase64Encoded: false 56 | }).should.be.true(); 57 | }); 58 | 59 | it('calls the callback with provided response values', () => { 60 | const h = makeHandler(); 61 | const cb = sinon.spy(); 62 | h.pool.put(id, {}, cb); 63 | h(id, { statusCode: '404', body: 'not found' }); 64 | cb.calledWith(null, { 65 | statusCode: 404, 66 | body: 'not found', 67 | headers: responseHandler.defaultHeaders(''), 68 | isBase64Encoded: false 69 | }).should.be.true(); 70 | }); 71 | 72 | it('JSON stringifies bodies which are objects', () => { 73 | const h = makeHandler(); 74 | const cb = sinon.spy(); 75 | h.pool.put(id, {}, cb); 76 | h(id, { statusCode: '200', body: { great: 'job' } }); 77 | cb.calledWith(null, { 78 | statusCode: 200, 79 | body: '{"great":"job"}', 80 | headers: responseHandler.defaultHeaders({}), 81 | isBase64Encoded: false 82 | }).should.be.true(); 83 | }); 84 | 85 | it('uses plain text for numbers', () => { 86 | const h = makeHandler(); 87 | const cb = sinon.spy(); 88 | h.pool.put(id, {}, cb); 89 | h(id, { statusCode: '200', body: 42 }); 90 | cb.calledWith(null, { 91 | statusCode: 200, 92 | body: '42', 93 | headers: responseHandler.defaultHeaders(''), 94 | isBase64Encoded: false 95 | }).should.be.true(); 96 | }); 97 | 98 | it('sets isBase64Encoded to true', () => { 99 | const h = makeHandler(); 100 | const cb = sinon.spy(); 101 | h.pool.put(id, {}, cb); 102 | h(id, { statusCode: '200', body: 42, isBase64Encoded: true }); 103 | cb.calledWith(null, { 104 | statusCode: 200, 105 | body: '42', 106 | headers: responseHandler.defaultHeaders(''), 107 | isBase64Encoded: true 108 | }).should.be.true(); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/bridge/spy-logger.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | 3 | const spyLogger = () => ({ 4 | info: sinon.spy(), 5 | warn: sinon.spy(), 6 | error: sinon.spy(), 7 | trace: sinon.spy(), 8 | }); 9 | 10 | module.exports = spyLogger; 11 | -------------------------------------------------------------------------------- /test/bridge/validate-spec.js: -------------------------------------------------------------------------------- 1 | const validate = require('../../src-bridge/validate'); 2 | 3 | const missing = 'is missing'; 4 | const invalid = 'is invalid'; 5 | const msg = { missing, invalid }; 6 | 7 | describe('validate(obj, attr, { missing, invalid })', () => { 8 | it('does nothing of the validation passes', () => { 9 | (() => validate({ foo: () => null }, 'foo', msg)) 10 | .should.not.throw(); 11 | }); 12 | 13 | it('throws if obj has not attribute named attr', () => { 14 | (() => validate({}, 'foo', msg)) 15 | .should.throw('is invalid: {}'); 16 | }); 17 | 18 | it('throws if obj.attr is not a function', () => { 19 | (() => validate({ foo: 'bar' }, 'foo', msg)) 20 | .should.throw("is invalid: { foo: 'bar' }"); 21 | }); 22 | 23 | it('throws if obj is undefined', () => { 24 | (() => validate(undefined, 'foo', msg)) 25 | .should.throw('is missing'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/demo/config-spec.js: -------------------------------------------------------------------------------- 1 | const request = require('./request'); 2 | 3 | const path = (relative) => `/config${relative}`; 4 | 5 | describe('Demo: /config', () => { 6 | it('has status 200', () => 7 | request.get(path('/')).expect(200) 8 | ); 9 | 10 | it('responds with the parsed config', () => 11 | request.get(path('/')) 12 | .then(res => { 13 | res.text.should.equal('Config: {"auth":{"secret":"secret"},"someService":{"protocol":"http","host":"localhost","port":3131}}'); 14 | }) 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /test/demo/forms-spec.js: -------------------------------------------------------------------------------- 1 | const request = require('./request'); 2 | 3 | const path = (relative) => `/forms${relative}`; 4 | 5 | describe('Demo: /forms', () => { 6 | it('GET has status 405', () => 7 | request.get(path('/')).expect(405) 8 | ); 9 | 10 | it('POST text/text has status 400', () => 11 | request 12 | .post(path('/')) 13 | .set('content-type', 'text/text') 14 | .expect(400) 15 | .then(res => { 16 | res.text.should.equal('Could not decode request body. Problem with the given value:\n\nnull\n\nExpecting an OBJECT with a field named `age`'); 17 | }) 18 | ); 19 | 20 | it('POST application/json invalid JSON has status 400', () => 21 | request 22 | .post(path('/')) 23 | .set('content-type', 'application/json') 24 | .send('{,}') 25 | .expect(400) 26 | .then(res => { 27 | res.text.should.equal('Could not decode request body. Problem with the given value:\n\n"{,}"\n\nThis is not valid JSON! Unexpected token , in JSON at position 1'); 28 | }) 29 | ); 30 | 31 | it('POST application/json wrong object has status 400', () => 32 | request 33 | .post(path('/')) 34 | .set('content-type', 'application/json') 35 | .send({ age: 4 }) 36 | .expect(400) 37 | .then(res => { 38 | res.text.should.equal('Could not decode request body. Problem with the given value:\n\n{\n "age": 4\n }\n\nExpecting an OBJECT with a field named `name`'); 39 | }) 40 | ); 41 | 42 | it('POST application/json correct object has status 200', () => 43 | request 44 | .post(path('/')) 45 | .set('content-type', 'application/json') 46 | .send({ age: 4, name: 'fred' }) 47 | .expect(200) 48 | .then(res => { 49 | res.text.should.equal('{"name":"fred","age":4}'); 50 | }) 51 | ); 52 | 53 | it('POST text/text with JSON body has status 200', () => 54 | request 55 | .post(path('/')) 56 | .set('content-type', 'text/text') 57 | .send('{"age":3,"name":"barney"}') 58 | .expect(200) 59 | .then(res => { 60 | res.text.should.equal('{"name":"barney","age":3}'); 61 | }) 62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /test/demo/hello-spec.js: -------------------------------------------------------------------------------- 1 | const request = require('./request'); 2 | 3 | const greeting = 'Hello Elm on AWS Lambda'; 4 | const path = (relative) => `${relative}`; 5 | 6 | describe('Demo: /', () => { 7 | describe('GET /', () => { 8 | it('has status 200', () => 9 | request.get(path('/')).expect(200) 10 | ); 11 | 12 | it('responds with plain text', () => 13 | request.get(path('/')) 14 | .then(res => { 15 | res.headers.should.have.property('content-type') 16 | .which.equal('text/text; charset=utf-8'); 17 | res.text.should.equal(greeting); 18 | }) 19 | ); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/demo/interop-spec.js: -------------------------------------------------------------------------------- 1 | const co = require('co'); 2 | const should = require('should'); 3 | 4 | const request = require('./request'); 5 | 6 | const path = (relative) => `/interop/${relative}`; 7 | 8 | describe('Demo: /interop', () => { 9 | describe('GET /unit', () => { 10 | it('gets a random float between 0 and 1', () => co(function* () { 11 | const n = 20; 12 | const responses = yield [...Array(n)].map(() => 13 | request.get(path('unit')).expect(200)); 14 | responses.forEach(({ body }) => { 15 | should(typeof body).equal('number'); 16 | body.toString().should.match(/^[01]\.\d+$/); 17 | body.should.be.aboveOrEqual(0); 18 | body.should.be.belowOrEqual(1); 19 | }); 20 | should(new Set(responses.map(r => r.body)).size).above(1); 21 | })); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/demo/pipelines-spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | 3 | const request = require('./request'); 4 | 5 | const path = (relative) => `/pipelines${relative}`; 6 | 7 | describe('Demo: /pipelines', () => { 8 | it('has status 401', () => 9 | request.get(path('/')).expect(401) 10 | ); 11 | 12 | it('terminates the pipeline early if Unauthorized', () => 13 | request.get(path('/')).expect(401).then(res => { 14 | should(res.headers).have.property('x-from-first-plug').equal('foo'); 15 | should(res.headers).not.have.property('x-from-last-plug'); 16 | res.text.should.startWith('Unauthorized'); 17 | }) 18 | ); 19 | 20 | it('includes response headers from cors middleware', () => 21 | request.get(path('/')).expect(401).then(res => { 22 | should(res.headers).have.property('access-control-allow-origin', '*'); 23 | should(res.headers).have.property('access-control-allow-methods', 'GET,POST,OPTIONS'); 24 | }) 25 | ); 26 | 27 | it('reflects origin from the request', () => 28 | request 29 | .get(path('/')) 30 | .set('origin', 'foo.bar.com') 31 | .expect(401) 32 | .then(res => { 33 | should(res.headers).have.property('access-control-allow-origin', 'foo.bar.com'); 34 | }) 35 | ); 36 | 37 | it('completes the pipeline if Authorized', () => 38 | request 39 | .get(path('/')) 40 | .set('Authorization', 'anything') 41 | .expect(200) 42 | .then(res => { 43 | should(res.headers).have.property('x-from-first-plug').equal('foo'); 44 | should(res.headers).have.property('x-from-last-plug').equal('bar'); 45 | res.text.should.equal('Pipeline applied'); 46 | }) 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /test/demo/quoted-spec.js: -------------------------------------------------------------------------------- 1 | const co = require('co'); 2 | const should = require('should'); 3 | 4 | const request = require('./request'); 5 | 6 | const path = (relative) => `/quoted${relative}`; 7 | 8 | describe('Demo: /quoted', () => { 9 | describe('GET /', () => { 10 | it('expects an Authorization header', () => 11 | request.get(path('/')) 12 | .expect(401) 13 | ); 14 | 15 | it('has status 200', () => 16 | request.get(path('/')) 17 | .set('Authorization', 'anything') 18 | .expect(200) 19 | ); 20 | 21 | it('responds with plain text', () => 22 | request.get(path('/')) 23 | .set('Authorization', 'anything') 24 | .then(res => { 25 | res.headers.should.have.property('content-type') 26 | .which.equal('text/text; charset=utf-8'); 27 | res.text.should.startWith('Home'); 28 | }) 29 | ); 30 | 31 | it('parses a single query parameter', () => 32 | request.get(path('/?q=foo%20bar')) 33 | .set('Authorization', 'anything') 34 | .expect(200).then(res => { 35 | res.text.should.match(/"q":"foo bar"/); 36 | }) 37 | ); 38 | 39 | it('parses a two query parameters', () => 40 | request.get(path('/?q=*&sort=asc')) 41 | .set('Authorization', 'anything') 42 | .expect(200).then(res => { 43 | res.text.should.match(/"q":"*"/); 44 | res.text.should.match(/"sort":"Asc"/); 45 | }) 46 | ); 47 | 48 | it('provides default query values', () => 49 | request.get(path('/')) 50 | .set('Authorization', 'anything') 51 | .expect(200).then(res => { 52 | res.text.should.match(/"q":""/); 53 | res.text.should.match(/"sort":"Desc"/); 54 | }) 55 | ); 56 | }); 57 | 58 | describe('POST /', () => { 59 | it('has status 405', () => 60 | request.post(path('/')) 61 | .set('Authorization', 'anything') 62 | .expect(405) 63 | ); 64 | }); 65 | 66 | describe('GET /buggy', () => { 67 | it('has status 500', () => 68 | request.get(path('/buggy')) 69 | .set('Authorization', 'anything') 70 | .expect(500) 71 | ); 72 | 73 | it('responds with plain text', () => 74 | request.get(path('/buggy')) 75 | .set('Authorization', 'anything') 76 | .then(res => { 77 | res.headers.should.have.property('content-type') 78 | .which.equal('text/text; charset=utf-8'); 79 | res.text.should.equal('bugs, bugs, bugs'); 80 | }) 81 | ); 82 | }); 83 | 84 | describe('GET /some-path-that-does-not-exist', () => { 85 | it('has status 404', () => 86 | request.get(path('/some-random-path')) 87 | .set('Authorization', 'anything') 88 | .expect(404) 89 | ); 90 | 91 | it('responds with plain text', () => 92 | request.get(path('/some-random-path')) 93 | .set('Authorization', 'anything') 94 | .then(res => { 95 | res.headers.should.have.property('content-type') 96 | .which.equal('text/text; charset=utf-8'); 97 | res.text.should.startWith('Could not parse route: '); 98 | }) 99 | ); 100 | }); 101 | 102 | describe('POST /quote', () => { 103 | it('has status 501', () => 104 | request.post(path('/quote')) 105 | .set('Authorization', 'anything') 106 | .expect(501).then(res => { 107 | res.text.should.match(/^Not implemented/); 108 | }) 109 | ); 110 | }); 111 | 112 | describe('PUT /quote', () => { 113 | it('has status 405', () => 114 | request.put(path('/quote')) 115 | .set('Authorization', 'anything') 116 | .expect(405).then(res => { 117 | res.text.should.equal('Method not allowed'); 118 | }) 119 | ); 120 | }); 121 | 122 | describe('GET /number', () => { 123 | it('has status 200', () => 124 | request.get(path('/number')) 125 | .set('Authorization', 'anything') 126 | .expect(200) 127 | ); 128 | 129 | it('returns a different value each time', () => co(function* () { 130 | const res0 = yield request.get(path('/number')).set('Authorization', 'anything').expect(200); 131 | const res1 = yield request.get(path('/number')).set('Authorization', 'anything').expect(200); 132 | should(typeof res0.body).equal('number'); 133 | res0.body.should.not.equal(res1.body); 134 | })); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/demo/request.js: -------------------------------------------------------------------------------- 1 | const defaults = require('superagent-defaults'); 2 | const supertest = require('supertest'); 3 | 4 | const port = 3001; 5 | const endpoint = `http://localhost:${port}`; 6 | 7 | module.exports = defaults(supertest(endpoint)); 8 | module.exports.port = port; 9 | -------------------------------------------------------------------------------- /test/demo/routing-spec.js: -------------------------------------------------------------------------------- 1 | const request = require('./request'); 2 | 3 | const path = (relative) => `/routing${relative}`; 4 | 5 | describe('Demo: /routing', () => { 6 | it('GET / routes to the home page', () => 7 | request.get(path('/')).expect(200).then(res => { 8 | res.text.should.equal('The home page'); 9 | }) 10 | ); 11 | 12 | it('GET /blog routes to the blog list', () => 13 | request.get(path('/blog')).expect(200).then(res => { 14 | res.text.should.equal('List of recent posts...'); 15 | }) 16 | ); 17 | 18 | it('GET /blog/some-slug routes to a specific post', () => 19 | request.get(path('/blog/some-slug')).expect(200).then(res => { 20 | res.text.should.equal('Specific post: some-slug'); 21 | }) 22 | ); 23 | 24 | it('POST /blog returns 405', () => 25 | request.post(path('/blog')).expect(405) 26 | ); 27 | 28 | it('GET /some/undefined/path returns 404', () => 29 | request.post(path('/some/undefined/path')).expect(404) 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /test/demo/side-effects-spec.js: -------------------------------------------------------------------------------- 1 | const co = require('co'); 2 | const should = require('should'); 3 | 4 | const request = require('./request'); 5 | 6 | const path = (relative) => `/side-effects/${relative}`; 7 | 8 | describe('Demo: /side-effects', () => { 9 | describe('GET /', () => { 10 | it('gets a random integer', () => co(function* () { 11 | const n = 20; 12 | const responses = yield [...Array(n)].map(() => 13 | request.get(path('/')).expect(200)); 14 | should(typeof responses[0].body).equal('number'); 15 | // Seed is probably current time, if a couple of requests start 16 | // at the same time, they will share the same seed. To fix this, 17 | // we'd have to add some way of sharing state between connections. 18 | should(new Set(responses.map(r => r.body)).size).above(1); 19 | })); 20 | }); 21 | 22 | describe('GET /:upper', () => { 23 | it('gets a random integer below :upper', () => co(function* () { 24 | const upper = 10; 25 | const n = 10; 26 | const responses = yield [...Array(n)].map(() => 27 | request.get(path(upper)).expect(200)); 28 | responses.forEach(({ body }) => { 29 | body.should.be.aboveOrEqual(0); 30 | body.should.be.belowOrEqual(upper); 31 | }); 32 | })); 33 | }); 34 | 35 | describe('GET /:lower/:upper', () => { 36 | it('gets a random integer between :lower and :upper', () => co(function* () { 37 | const n = 50; 38 | // This one also tests that messages get mapped to the appropriate 39 | // connection. 40 | const ranges = [...Array(n)].map((_, i) => [i * 10, ((i + 1) * 10) - 1]); 41 | const responses = yield ranges.map(range => 42 | request.get(path(range.join('/'))).expect(200)); 43 | ranges.forEach(([lower, upper], index) => { 44 | const { body } = responses[index]; 45 | body.should.be.aboveOrEqual(lower); 46 | body.should.be.belowOrEqual(upper); 47 | }); 48 | })); 49 | }); 50 | 51 | describe('GET /unit', () => { 52 | it('gets a random float between 0 and 1', () => co(function* () { 53 | const n = 20; 54 | const responses = yield [...Array(n)].map(() => 55 | request.get(path('unit')).expect(200)); 56 | responses.forEach(({ body }) => { 57 | should(typeof body).equal('number'); 58 | body.toString().should.match(/^[01]\.\d+$/); 59 | body.should.be.aboveOrEqual(0); 60 | body.should.be.belowOrEqual(1); 61 | }); 62 | should(new Set(responses.map(r => r.body)).size).above(1); 63 | })); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require should 2 | --recursive 3 | -------------------------------------------------------------------------------- /tests/Serverless/Conn/DecodeTests.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.DecodeTests exposing (all) 2 | 3 | import Json.Encode as Encode 4 | import Serverless.Conn.Body as Body exposing (Body) 5 | import Serverless.Conn.IpAddress as IpAddress exposing (IpAddress) 6 | import Serverless.Conn.KeyValueList as KeyValueList 7 | import Serverless.Conn.Request as Request 8 | import Test exposing (describe) 9 | import Test.Extra exposing (DecoderExpectation(..), describeDecoder) 10 | 11 | 12 | all : Test.Test 13 | all = 14 | describe "Serverless.Conn.Decode" 15 | [ describeDecoder "KeyValueList.decoder" 16 | KeyValueList.decoder 17 | Debug.toString 18 | [ ( "null", DecodesTo [] ) 19 | , ( "{}", DecodesTo [] ) 20 | , ( """{ "fOo": "baR " }""", DecodesTo [ ( "fOo", "baR " ) ] ) 21 | , ( """{ "foo": 3 }""", FailsToDecode ) 22 | ] 23 | , describeDecoder "body for plain text" 24 | (Body.decoder Nothing) 25 | Debug.toString 26 | [ ( "null", DecodesTo Body.empty ) 27 | , ( "\"\"", DecodesTo (Body.text "") ) 28 | , ( "\"foo bar\\ncar\"", DecodesTo (Body.text "foo bar\ncar") ) 29 | , ( "\"{}\"", DecodesTo (Body.text "{}") ) 30 | ] 31 | , describeDecoder "body for json" 32 | (Body.decoder <| Just "application/json") 33 | Debug.toString 34 | [ ( "null", DecodesTo Body.empty ) 35 | , ( "\"{}\"", DecodesTo (Body.json <| Encode.object []) ) 36 | ] 37 | , describeDecoder "ip" 38 | IpAddress.decoder 39 | Debug.toString 40 | [ ( "null", FailsToDecode ) 41 | , ( "\"\"", FailsToDecode ) 42 | , ( "\"1.2.3\"", FailsToDecode ) 43 | , ( "\"1.2.3.4\"", DecodesTo (IpAddress.ip4 1 2 3 4) ) 44 | , ( "\"1.2.3.4.5\"", FailsToDecode ) 45 | , ( "\"1.2.-3.4\"", FailsToDecode ) 46 | ] 47 | , describeDecoder "Request.methodDecoder" 48 | Request.methodDecoder 49 | Debug.toString 50 | [ ( "null", FailsToDecode ) 51 | , ( "\"\"", FailsToDecode ) 52 | , ( "\"fizz\"", FailsToDecode ) 53 | , ( "\"GET\"", DecodesTo Request.GET ) 54 | , ( "\"get\"", DecodesTo Request.GET ) 55 | , ( "\"gEt\"", DecodesTo Request.GET ) 56 | , ( "\"POST\"", DecodesTo Request.POST ) 57 | , ( "\"PUT\"", DecodesTo Request.PUT ) 58 | , ( "\"DELETE\"", DecodesTo Request.DELETE ) 59 | , ( "\"OPTIONS\"", DecodesTo Request.OPTIONS ) 60 | , ( "\"Trace\"", DecodesTo Request.TRACE ) 61 | , ( "\"head\"", DecodesTo Request.HEAD ) 62 | , ( "\"PATCH\"", DecodesTo Request.PATCH ) 63 | ] 64 | , describeDecoder "Request.schemeDecoder" 65 | Request.schemeDecoder 66 | Debug.toString 67 | [ ( "null", FailsToDecode ) 68 | , ( "\"\"", FailsToDecode ) 69 | , ( "\"http\"", DecodesTo Request.Http ) 70 | , ( "\"https\"", DecodesTo Request.Https ) 71 | , ( "\"HTTP\"", DecodesTo Request.Http ) 72 | , ( "\"httpsx\"", FailsToDecode ) 73 | ] 74 | , describeDecoder "Request.decoder" 75 | Request.decoder 76 | Debug.toString 77 | [ ( "", FailsToDecode ) 78 | , ( "{}", FailsToDecode ) 79 | , ( """ 80 | { 81 | "body": null, 82 | "headers": null, 83 | "host": "", 84 | "method": "GeT", 85 | "path": "/", 86 | "port": 80, 87 | "remoteIp": "127.0.0.1", 88 | "scheme": "http", 89 | "stage": "test", 90 | "queryParams": null, 91 | "queryString": "" 92 | } 93 | """ 94 | , DecodesTo Request.init 95 | ) 96 | ] 97 | ] 98 | -------------------------------------------------------------------------------- /tests/Serverless/Conn/EncodeTests.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.EncodeTests exposing (all) 2 | 3 | import Expect 4 | import Expect.Extra as Expect exposing (stringPattern) 5 | import Json.Encode as Encode 6 | import Serverless.Conn as Conn exposing (updateResponse) 7 | import Serverless.Conn.Body as Body 8 | import Serverless.Conn.Response as Response exposing (addHeader) 9 | import Serverless.Conn.Test as Test 10 | import Test exposing (describe, test) 11 | 12 | 13 | all : Test.Test 14 | all = 15 | describe "Serverless.Conn.Encode" 16 | [ describe "encodeBody" 17 | [ test "encodes NoBody as null" <| 18 | \_ -> 19 | Expect.equal Encode.null (Body.encode Body.empty) 20 | , test "encodes TextBody to plain text" <| 21 | \_ -> 22 | Expect.equal 23 | (Encode.string "abc123") 24 | (Body.text "abc123" |> Body.encode) 25 | ] 26 | , describe "encodeResponse" 27 | [ Test.conn "contains the most recent header (when a header is set more than once)" <| 28 | updateResponse 29 | (addHeader ( "content-type", "text/text" ) 30 | >> addHeader ( "content-type", "application/xml" ) 31 | ) 32 | >> Conn.jsonEncodedResponse 33 | >> Encode.encode 0 34 | >> Expect.match 35 | (stringPattern "\"content-type\":\"application/xml\"") 36 | ] 37 | ] 38 | -------------------------------------------------------------------------------- /tests/Serverless/Conn/Fuzz.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.Fuzz exposing 2 | ( body 3 | , conn 4 | , header 5 | , request 6 | , status 7 | ) 8 | 9 | import Fuzz exposing (Fuzzer, andMap, constant, map) 10 | import Fuzz.Extra exposing (eitherOr) 11 | import Serverless.Conn exposing (Id) 12 | import Serverless.Conn.Body as Body exposing (Body) 13 | import Serverless.Conn.Request as Request exposing (Method(..), Request) 14 | import Serverless.Conn.Response as Response exposing (Response, Status) 15 | import TestHelpers exposing (Config, Conn, Model, Route(..)) 16 | 17 | 18 | conn : Fuzzer Conn 19 | conn = 20 | Fuzz.map5 Serverless.Conn.init 21 | id 22 | (constant (Config "secret")) 23 | (constant (Model 0)) 24 | (constant Home) 25 | request 26 | 27 | 28 | 29 | -- response 30 | 31 | 32 | response : Fuzzer Response 33 | response = 34 | constant Response.init 35 | 36 | 37 | 38 | -- request 39 | 40 | 41 | request : Fuzzer Request 42 | request = 43 | constant Request.init 44 | 45 | 46 | id : Fuzzer Id 47 | id = 48 | constant "8d66a836-6e4e-11e7-907b-a6006ad3dba0" 49 | 50 | 51 | body : Fuzzer Body 52 | body = 53 | eitherOr 54 | (constant Body.empty) 55 | textBody 56 | 57 | 58 | textBody : Fuzzer Body 59 | textBody = 60 | constant (Body.text "some text body") 61 | 62 | 63 | headers : Fuzzer (List ( String, String )) 64 | headers = 65 | eitherOr 66 | (constant []) 67 | (map toList header) 68 | 69 | 70 | header : Fuzzer ( String, String ) 71 | header = 72 | constant ( "some-header", "Some Value" ) 73 | 74 | 75 | host : Fuzzer String 76 | host = 77 | eitherOr 78 | (constant "localhost") 79 | (constant "sub.dom.ain.tv") 80 | 81 | 82 | method : Fuzzer Method 83 | method = 84 | eitherOr 85 | (constant GET) 86 | (constant POST) 87 | 88 | 89 | path : Fuzzer String 90 | path = 91 | eitherOr 92 | (constant "/") 93 | (constant "/foo/bar-dy/8/car_dy") 94 | 95 | 96 | status : Fuzzer Status 97 | status = 98 | constant 200 99 | 100 | 101 | queryParams : Fuzzer (List ( String, String )) 102 | queryParams = 103 | eitherOr 104 | (constant []) 105 | (constant [ ( "page", "123" ) ]) 106 | 107 | 108 | 109 | -- helpers 110 | 111 | 112 | toList : a -> List a 113 | toList = 114 | \x -> [ x ] 115 | -------------------------------------------------------------------------------- /tests/Serverless/Conn/PoolTests.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.PoolTests exposing (all) 2 | 3 | import Expect 4 | import Expect.Extra as Expect exposing (regexPattern) 5 | import Json.Encode 6 | import Serverless.Conn as Conn 7 | import Serverless.Conn.Pool as Pool 8 | import Serverless.Conn.Response as Response exposing (Response, Status, setStatus) 9 | import Serverless.Conn.Test as Test 10 | import Test exposing (describe, test) 11 | 12 | 13 | all : Test.Test 14 | all = 15 | describe "Serverless.Pool" 16 | [ describe "empty" 17 | [ test "creates a pool with no connections" <| 18 | \_ -> 19 | Expect.equal 0 (Pool.size Pool.empty) 20 | ] 21 | , describe "replace" 22 | [ Test.conn "adds a connection to a pool" <| 23 | \conn -> 24 | Expect.equal 25 | 1 26 | (Pool.empty 27 | |> Pool.replace conn 28 | |> Pool.size 29 | ) 30 | , Test.conn "replaces an existing connection in a pool" <| 31 | \conn -> 32 | Expect.match 33 | (regexPattern "\"statusCode\":403\\b") 34 | (Pool.empty 35 | |> Pool.replace conn 36 | |> Pool.replace (Conn.updateResponse (setStatus 403) conn) 37 | |> Pool.get (Conn.id conn) 38 | |> Maybe.map (Conn.jsonEncodedResponse >> Json.Encode.encode 0) 39 | |> Maybe.withDefault "" 40 | ) 41 | ] 42 | , describe "remove" 43 | [ Test.conn "removes a connection from a pool" <| 44 | \conn -> 45 | Expect.equal 46 | 0 47 | (Pool.empty 48 | |> Pool.replace conn 49 | |> Pool.remove conn 50 | |> Pool.size 51 | ) 52 | , Test.conn "does nothing if the connection is not in the pool" <| 53 | \conn -> 54 | Expect.equal 55 | 0 56 | (Pool.empty 57 | |> Pool.remove conn 58 | |> Pool.size 59 | ) 60 | ] 61 | ] 62 | 63 | 64 | initResponseTest : String -> (Response -> Expect.Expectation) -> Test.Test 65 | initResponseTest label e = 66 | test label <| 67 | \_ -> 68 | e Response.init 69 | -------------------------------------------------------------------------------- /tests/Serverless/Conn/Test.elm: -------------------------------------------------------------------------------- 1 | module Serverless.Conn.Test exposing (conn, connWith, request) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz exposing (Fuzzer) 5 | import Serverless.Conn.Fuzz as Fuzz 6 | import Serverless.Conn.Request exposing (Request) 7 | import Test exposing (Test) 8 | import TestHelpers exposing (Conn) 9 | 10 | 11 | conn : String -> (Conn -> Expectation) -> Test 12 | conn label = 13 | Test.fuzz Fuzz.conn label 14 | 15 | 16 | connWith : Fuzzer a -> String -> (( Conn, a ) -> Expectation) -> Test 17 | connWith otherFuzzer label = 18 | Test.fuzz (Fuzz.tuple ( Fuzz.conn, otherFuzzer )) label 19 | 20 | 21 | request : String -> (Request -> Expectation) -> Test 22 | request label = 23 | Test.fuzz Fuzz.request label 24 | -------------------------------------------------------------------------------- /tests/Serverless/ConnTests.elm: -------------------------------------------------------------------------------- 1 | module Serverless.ConnTests exposing 2 | ( all 3 | , buildingPipelinesTests 4 | , responseTests 5 | ) 6 | 7 | import Expect 8 | import Expect.Extra as Expect exposing (stringPattern) 9 | import Json.Encode 10 | import Serverless.Conn as Conn 11 | exposing 12 | ( send 13 | , toSent 14 | , unsent 15 | , updateResponse 16 | ) 17 | import Serverless.Conn.Body exposing (binary, text) 18 | import Serverless.Conn.Fuzz as Fuzz 19 | import Serverless.Conn.Response as Response exposing (addHeader, setBody, setStatus) 20 | import Serverless.Conn.Test as Test 21 | import Serverless.Plug as Plug 22 | import Test exposing (describe, test) 23 | import TestHelpers exposing (..) 24 | 25 | 26 | all : Test.Test 27 | all = 28 | describe "Serverless.Conn" 29 | [ buildingPipelinesTests 30 | , responseTests 31 | ] 32 | 33 | 34 | 35 | -- BUILDING PIPELINES TESTS 36 | 37 | 38 | sp : Conn -> Conn 39 | sp = 40 | simplePlug "" 41 | 42 | 43 | sl : Msg -> Conn -> ( Conn, Cmd Msg ) 44 | sl = 45 | simpleLoop "" 46 | 47 | 48 | buildingPipelinesTests : Test.Test 49 | buildingPipelinesTests = 50 | describe "Building Pipelines" 51 | [ describe "pipeline" 52 | [ test "begins a pipeline" <| 53 | \_ -> 54 | Expect.equal 0 (Plug.size Plug.pipeline) 55 | ] 56 | , describe "plug" 57 | [ test "extends the pipeline by 1" <| 58 | \_ -> 59 | Expect.equal 1 (Plug.pipeline |> Plug.plug sp |> Plug.size) 60 | ] 61 | , describe "nest" 62 | [ test "extends the pipeline by the length of the nested pipeline" <| 63 | \_ -> 64 | Expect.equal 65 | 5 66 | (Plug.pipeline 67 | |> Plug.plug sp 68 | |> Plug.plug sp 69 | |> Plug.nest 70 | (Plug.pipeline 71 | |> Plug.plug sp 72 | |> Plug.plug sp 73 | |> Plug.plug sp 74 | ) 75 | |> Plug.size 76 | ) 77 | ] 78 | ] 79 | 80 | 81 | 82 | -- RESPONSE TESTS 83 | 84 | 85 | responseTests : Test.Test 86 | responseTests = 87 | describe "Responding " 88 | [ Test.conn "body sets the response body" <| 89 | \conn -> 90 | conn 91 | |> updateResponse (setBody <| text "hello") 92 | |> Conn.jsonEncodedResponse 93 | |> Json.Encode.encode 0 94 | |> Expect.match (stringPattern "\"body\":\"hello\"") 95 | , Test.conn "status sets the response status" <| 96 | \conn -> 97 | conn 98 | |> updateResponse (setStatus 200) 99 | |> Conn.jsonEncodedResponse 100 | |> Json.Encode.encode 0 101 | |> Expect.match (stringPattern "\"statusCode\":200") 102 | , Test.conn "send sets the response to Sent" <| 103 | \conn -> 104 | Expect.equal Nothing (toSent conn |> unsent) 105 | , Test.connWith Fuzz.header "headers adds a response header" <| 106 | \( conn, ( key, value ) ) -> 107 | conn 108 | |> updateResponse (addHeader ( key, value )) 109 | |> Conn.jsonEncodedResponse 110 | |> Json.Encode.encode 0 111 | |> Expect.match (stringPattern ("\"" ++ key ++ "\":\"" ++ value ++ "\"")) 112 | , Test.conn "binary sets the response header" <| 113 | \conn -> 114 | conn 115 | |> updateResponse (setBody <| binary "application/octet-stream" "hello") 116 | |> Conn.jsonEncodedResponse 117 | |> Json.Encode.encode 0 118 | |> Expect.match (stringPattern "\"content-type\":\"application/octet-stream; charset=utf-8\"") 119 | , Test.conn "binary sets isBase64Encoded to true" <| 120 | \conn -> 121 | conn 122 | |> updateResponse (setBody <| binary "application/octet-stream" "hello") 123 | |> Conn.jsonEncodedResponse 124 | |> Json.Encode.encode 0 125 | |> Expect.match (stringPattern "\"isBase64Encoded\":true") 126 | ] 127 | -------------------------------------------------------------------------------- /tests/TestHelpers.elm: -------------------------------------------------------------------------------- 1 | module TestHelpers exposing (Config, Conn, Model, Msg(..), Plug, Route(..), appendToBody, conn, getHeader, httpGet, requestPort, responsePort, route, simpleLoop, simplePlug) 2 | 3 | import Json.Encode as Encode 4 | import Regex 5 | import Serverless.Conn as Conn exposing (updateResponse) 6 | import Serverless.Conn.Body as Body exposing (appendText) 7 | import Serverless.Conn.Request as Request exposing (Request) 8 | import Serverless.Conn.Response as Response exposing (Response, updateBody) 9 | import Serverless.Plug as Plug exposing (pipeline, plug) 10 | import Url.Parser exposing ((), Parser, map, oneOf, s, string, top) 11 | 12 | 13 | appendToBody : String -> Conn -> Conn 14 | appendToBody x conn_ = 15 | updateResponse 16 | (updateBody 17 | (\body -> 18 | case appendText x body of 19 | Ok newBody -> 20 | newBody 21 | 22 | Err err -> 23 | Debug.todo "crash" 24 | ) 25 | ) 26 | conn_ 27 | 28 | 29 | simplePlug : String -> Conn -> Conn 30 | simplePlug = 31 | appendToBody 32 | 33 | 34 | simpleLoop : String -> Msg -> Conn -> ( Conn, Cmd Msg ) 35 | simpleLoop label msg conn_ = 36 | ( conn_ |> appendToBody label, Cmd.none ) 37 | 38 | 39 | 40 | -- ROUTING 41 | 42 | 43 | type Route 44 | = Home 45 | | Foody String 46 | | NoCanFind 47 | 48 | 49 | route : Parser (Route -> a) a 50 | route = 51 | oneOf 52 | [ map Home top 53 | , map Foody (s "foody" string) 54 | ] 55 | 56 | 57 | 58 | -- DOC TESTS 59 | 60 | 61 | conn : Conn 62 | conn = 63 | Conn.init "id" (Config "secret") (Model 0) Home Request.init 64 | 65 | 66 | getHeader : String -> Conn -> Maybe String 67 | getHeader key conn_ = 68 | conn_ 69 | |> Conn.jsonEncodedResponse 70 | |> Encode.encode 0 71 | |> Regex.findAtMost 1 (Maybe.withDefault Regex.never <| Regex.fromString <| "\"" ++ key ++ "\":\"(.*?)\"") 72 | |> List.head 73 | |> Maybe.andThen (\{ submatches } -> List.head submatches) 74 | |> Maybe.andThen (\x -> x) 75 | 76 | 77 | httpGet : String -> a -> Cmd msg 78 | httpGet _ _ = 79 | Cmd.none 80 | 81 | 82 | 83 | -- TYPES 84 | 85 | 86 | type alias Config = 87 | { secret : String 88 | } 89 | 90 | 91 | type alias Model = 92 | { counter : Int 93 | } 94 | 95 | 96 | type Msg 97 | = NoOp 98 | 99 | 100 | type alias Plug = 101 | Plug.Plug Config Model Route 102 | 103 | 104 | type alias Conn = 105 | Conn.Conn Config Model Route 106 | 107 | 108 | requestPort : (Encode.Value -> msg) -> Sub msg 109 | requestPort _ = 110 | Sub.none 111 | 112 | 113 | responsePort : Encode.Value -> Cmd msg 114 | responsePort _ = 115 | -- We don't use Cmd.none because some tests compare values sent to the 116 | -- response port to Cmd.none, to make sure something was actually sent 117 | Cmd.batch [ Cmd.none ] 118 | -------------------------------------------------------------------------------- /tests/elm-doc-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "../src", 3 | "tests": [ 4 | "Serverless.Conn", 5 | "Serverless.Conn.Body", 6 | "Serverless.Conn.IpAddress", 7 | "Serverless.Plug" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tests/elm-verify-examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "../src", 3 | "tests": [] 4 | } 5 | -------------------------------------------------------------------------------- /tests/elm.json.elmupgrade: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | ".", 5 | "../src" 6 | ], 7 | "elm-version": "0.19.0", 8 | "dependencies": { 9 | "direct": { 10 | "NoRedInk/elm-json-decode-pipeline": "1.0.0", 11 | "elm/core": "1.0.2", 12 | "elm/html": "1.0.0", 13 | "elm/json": "1.1.3", 14 | "elm/random": "1.0.0", 15 | "elm/regex": "1.0.0", 16 | "elm-community/json-extra": "4.0.0", 17 | "elm-explorations/test": "1.2.1", 18 | "ktonon/elm-test-extra": "2.0.1", 19 | "danielnarey/elm-toolkit": "4.5.0", 20 | "elm-community/lazy-list": "1.0.0", 21 | "elm-community/shrink": "2.0.0", 22 | "ktonon/url-parser": "1.0.0" 23 | }, 24 | "indirect": { 25 | "elm/parser": "1.1.0", 26 | "elm/time": "1.0.0", 27 | "elm/virtual-dom": "1.0.2", 28 | "rtfeldman/elm-iso8601-date-strings": "1.1.3" 29 | } 30 | }, 31 | "test-dependencies": { 32 | "direct": {}, 33 | "indirect": {} 34 | } 35 | } --------------------------------------------------------------------------------