diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..b8e1fd1 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,8 @@ +module.exports = { + tabWidth: 2, + trailingComma: 'all', + singleQuote: true, + semi: false, + arrowParens: 'avoid', + plugins: [], +} diff --git a/.spec-results b/.spec-results new file mode 100644 index 0000000..7659b8c --- /dev/null +++ b/.spec-results @@ -0,0 +1,121 @@ +[ + [ + "Axon Request Parts Body extracts a JSON body", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Body extracts a string body from a buffer", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Body extracts a string body from a cached string", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Body extracts a string body from a readable stream", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Path ... but does if ends in IgnoreRest", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Path does not partially match a route ...", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Path extracts an int", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Path extracts an int and a string", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Path matches a route matching literal", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts Path matches a route matching multiple literals", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts extracts a string body", + { + "timestamp": "1733092879598.0", + "success": true + } + ], + [ + "Axon Request Parts extracts a string body from a buffer", + { + "timestamp": "1733093315868.0", + "success": true + } + ], + [ + "Axon Request Parts extracts a string body from a cached string", + { + "timestamp": "1733093315868.0", + "success": true + } + ], + [ + "Axon Request Parts extracts a string body from a readable stream", + { + "timestamp": "1733093315868.0", + "success": true + } + ], + [ + "Axon Request Parts extracts method, path, JSON body", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Axon Request Parts extracts the whole request", + { + "timestamp": "1733094319834.0", + "success": true + } + ], + [ + "Parts extracts the whole request", + { + "timestamp": "1733092816548.0", + "success": true + } + ] +] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5d0a25 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# axon diff --git a/jsconfig.json b/jsconfig.json index 361136a..770c253 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -12,8 +12,5 @@ "noEmit": true, "strict": true }, - "include": [ - "./scripts/*.js", - "src/**/*.js" - ] + "include": ["./scripts/*.js", "src/**/*.js"] } diff --git a/scripts/common.js b/scripts/common.js new file mode 100644 index 0000000..1f8bd75 --- /dev/null +++ b/scripts/common.js @@ -0,0 +1,20 @@ +import Fs from 'fs/promises' +import Path from 'path' + +export const rootDir = Path.resolve(__dirname, '..') + +export const packageDirs = async () => ['./src'] + +export const packageSources = async () => { + const packages = await packageDirs() + const sources = [] + for (const p of packages) { + const files = await Fs.readdir(p, { recursive: true, withFileTypes: true }) + sources.push( + ...files.flatMap(e => + e.isFile() ? [Path.resolve(rootDir, e.path, e.name)] : [], + ), + ) + } + return sources +} diff --git a/scripts/fmt.js b/scripts/fmt.js new file mode 100644 index 0000000..da62c6c --- /dev/null +++ b/scripts/fmt.js @@ -0,0 +1,38 @@ +import { $ } from 'bun' +import { packageSources } from './common.js' + +const check = process.argv.includes('--check') + +const sources = await packageSources() +const purs = sources.filter(f => f.endsWith('.purs')) +const js = sources + .filter(f => f.endsWith('.js')) + .concat(['./scripts/**/*.js', '.prettierrc.cjs']) +const json = ['package.json', 'jsconfig.json'] +const yml = sources.filter(f => f.endsWith('.yaml')).concat(['spago.yaml']) + +/** @type {(parser: string, ps: string[]) => import("bun").ShellPromise} */ +const prettier = (parser, ps) => + $`bun x prettier ${check ? '--check' : '--write'} '--parser' ${parser} ${ps}` + +const procs = [ + () => prettier('babel', js), + () => prettier('json', json), + () => prettier('yaml', yml), + () => + prettier( + 'markdown', + sources.filter(f => f.endsWith('.md')).concat(['README.md']), + ), + () => $`bun x purs-tidy ${check ? 'check' : 'format-in-place'} ${purs}`, +] + .map(go => async () => { + const p = await go().nothrow().quiet() + if (p.exitCode === 0) return + process.stdout.write(p.stdout) + process.stderr.write(p.stderr) + process.exit(1) + }) + .reduce((acc, go) => acc.then(() => go()), Promise.resolve()) + +await procs diff --git a/spago.lock b/spago.lock index e4b8bfa..90fcee4 100644 --- a/spago.lock +++ b/spago.lock @@ -1,7 +1,7 @@ { "workspace": { "packages": { - "tower": { + "axon": { "path": "./", "core": { "dependencies": [ @@ -101,8 +101,84 @@ ] }, "test": { - "dependencies": [], - "build_plan": [] + "dependencies": [ + "spec", + "spec-node" + ], + "build_plan": [ + "aff", + "ansi", + "argonaut-codecs", + "argonaut-core", + "arraybuffer-types", + "arrays", + "avar", + "bifunctors", + "catenable-lists", + "console", + "const", + "contravariant", + "control", + "datetime", + "distributive", + "effect", + "either", + "enums", + "exceptions", + "exists", + "exitcodes", + "foldable-traversable", + "foreign", + "foreign-object", + "fork", + "free", + "functions", + "functors", + "gen", + "identity", + "integers", + "invariant", + "js-date", + "lazy", + "lists", + "maybe", + "mmorph", + "newtype", + "node-buffer", + "node-event-emitter", + "node-fs", + "node-path", + "node-process", + "node-streams", + "nonempty", + "now", + "nullable", + "numbers", + "open-memoize", + "optparse", + "ordered-collections", + "orders", + "parallel", + "partial", + "pipes", + "posix-types", + "prelude", + "profunctor", + "record", + "refs", + "safe-coerce", + "spec", + "spec-node", + "st", + "strings", + "tailrec", + "transformers", + "tuples", + "type-equality", + "typelevel-prelude", + "unfoldable", + "unsafe-coerce" + ] } } }, @@ -658,6 +734,16 @@ "foreign" ] }, + "ansi": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-ZMB6HD+q9CXvn9fRCmJ8dvuDrOVHcjombL3oNOerVnE=", + "dependencies": [ + "foldable-traversable", + "lists", + "strings" + ] + }, "argonaut-codecs": { "type": "registry", "version": "9.1.0", @@ -721,6 +807,19 @@ "unsafe-coerce" ] }, + "avar": { + "type": "registry", + "version": "5.0.0", + "integrity": "sha256-e7hf0x4hEpcygXP0LtvfvAQ49Bbj2aWtZT3gqM///0A=", + "dependencies": [ + "aff", + "effect", + "either", + "exceptions", + "functions", + "maybe" + ] + }, "b64": { "type": "registry", "version": "0.0.8", @@ -749,6 +848,20 @@ "tuples" ] }, + "catenable-lists": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-76vYENhwF4BWTBsjeLuErCH2jqVT4M3R1HX+4RwSftA=", + "dependencies": [ + "control", + "foldable-traversable", + "lists", + "maybe", + "prelude", + "tuples", + "unfoldable" + ] + }, "console": { "type": "registry", "version": "6.1.0", @@ -892,6 +1005,14 @@ "unsafe-coerce" ] }, + "exitcodes": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-4wxViTbyOoyKJ/WaRGI6+hZmgMKI5Miv16lSwefiLSM=", + "dependencies": [ + "enums" + ] + }, "ezfetch": { "type": "registry", "version": "1.1.0", @@ -994,6 +1115,35 @@ "unfoldable" ] }, + "fork": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-X7u0SuCvFbLbzuNEKLBNuWjmcroqMqit4xEzpQwAP7E=", + "dependencies": [ + "aff" + ] + }, + "free": { + "type": "registry", + "version": "7.1.0", + "integrity": "sha256-JAumgEsGSzJCNLD8AaFvuX7CpqS5yruCngi6yI7+V5k=", + "dependencies": [ + "catenable-lists", + "control", + "distributive", + "either", + "exists", + "foldable-traversable", + "invariant", + "lazy", + "maybe", + "prelude", + "tailrec", + "transformers", + "tuples", + "unsafe-coerce" + ] + }, "functions": { "type": "registry", "version": "6.0.0", @@ -1145,6 +1295,16 @@ "prelude" ] }, + "mmorph": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-urZlZNNqGeQFe5D/ClHlR8QgGBNHTMFPtJ5S5IpflTQ=", + "dependencies": [ + "free", + "functors", + "transformers" + ] + }, "newtype": { "type": "registry", "version": "5.0.0", @@ -1232,6 +1392,22 @@ "effect" ] }, + "node-process": { + "type": "registry", + "version": "11.2.0", + "integrity": "sha256-+2MQDYChjGbVbapCyJtuWYwD41jk+BntF/kcOTKBMVs=", + "dependencies": [ + "effect", + "foreign", + "foreign-object", + "maybe", + "node-event-emitter", + "node-streams", + "posix-types", + "prelude", + "unsafe-coerce" + ] + }, "node-streams": { "type": "registry", "version": "9.0.0", @@ -1288,6 +1464,22 @@ "maybe" ] }, + "open-memoize": { + "type": "registry", + "version": "6.2.0", + "integrity": "sha256-p1m7wF3aHQ80yUvqMs20OTMl496WS6YpKlmI2Nkg9j0=", + "dependencies": [ + "either", + "integers", + "lazy", + "lists", + "maybe", + "partial", + "prelude", + "strings", + "tuples" + ] + }, "options": { "type": "registry", "version": "7.0.0", @@ -1300,6 +1492,43 @@ "tuples" ] }, + "optparse": { + "type": "registry", + "version": "5.0.1", + "integrity": "sha256-cEzEkNW4q0gZlXl4z0zn+H2vs6l2UAp7NPHCsois73k=", + "dependencies": [ + "aff", + "arrays", + "bifunctors", + "console", + "control", + "effect", + "either", + "enums", + "exists", + "exitcodes", + "foldable-traversable", + "free", + "gen", + "integers", + "lazy", + "lists", + "maybe", + "newtype", + "node-buffer", + "node-process", + "node-streams", + "nonempty", + "numbers", + "open-memoize", + "partial", + "prelude", + "strings", + "tailrec", + "transformers", + "tuples" + ] + }, "ordered-collections": { "type": "registry", "version": "3.2.0", @@ -1351,6 +1580,29 @@ "integrity": "sha256-fwXerld6Xw1VkReh8yeQsdtLVrjfGiVuC5bA1Wyo/J4=", "dependencies": [] }, + "pipes": { + "type": "registry", + "version": "8.0.0", + "integrity": "sha256-kvfqGM4cPA/wCcBHbp5psouFw5dZGvku2462x7ZBwSY=", + "dependencies": [ + "aff", + "lists", + "mmorph", + "prelude", + "tailrec", + "transformers", + "tuples" + ] + }, + "posix-types": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-ZfFz8RR1lee/o/Prccyeut3Q+9tYd08mlR72sIh6GzA=", + "dependencies": [ + "maybe", + "prelude" + ] + }, "prelude": { "type": "registry", "version": "6.0.1", @@ -1415,6 +1667,72 @@ "variant" ] }, + "spec": { + "type": "registry", + "version": "8.1.1", + "integrity": "sha256-EM7UfQIaSgiw13LJ4ZASkfYmmRDKIlec3nYbGKFqGhk=", + "dependencies": [ + "aff", + "ansi", + "arrays", + "avar", + "bifunctors", + "control", + "datetime", + "effect", + "either", + "exceptions", + "foldable-traversable", + "fork", + "identity", + "integers", + "lists", + "maybe", + "newtype", + "now", + "ordered-collections", + "parallel", + "pipes", + "prelude", + "refs", + "strings", + "tailrec", + "transformers", + "tuples" + ] + }, + "spec-node": { + "type": "registry", + "version": "0.0.3", + "integrity": "sha256-Bjzg6l4uOfMN/FV0SKuT1Mm8eMP9sloLGVcY/0MeMnI=", + "dependencies": [ + "aff", + "argonaut-codecs", + "argonaut-core", + "arrays", + "control", + "datetime", + "effect", + "either", + "foldable-traversable", + "identity", + "integers", + "maybe", + "newtype", + "node-buffer", + "node-fs", + "node-process", + "now", + "numbers", + "optparse", + "ordered-collections", + "partial", + "prelude", + "spec", + "strings", + "tuples" + ] + }, "st": { "type": "registry", "version": "6.2.0", diff --git a/spago.yaml b/spago.yaml index a911475..9d1d26c 100644 --- a/spago.yaml +++ b/spago.yaml @@ -19,7 +19,9 @@ package: - web-streams test: main: Test.Main - dependencies: [] + dependencies: + - spec + - spec-node workspace: packageSet: registry: 61.2.0 diff --git a/src/Axon.Request.Method.purs b/src/Axon.Request.Method.purs index 8b34792..60509a4 100644 --- a/src/Axon.Request.Method.purs +++ b/src/Axon.Request.Method.purs @@ -8,9 +8,11 @@ import Data.Show.Generic (genericShow) import Data.String as String data Method = GET | POST | PUT | PATCH | DELETE | OPTIONS | TRACE | CONNECT + derive instance Generic Method _ derive instance Eq Method -instance Show Method where show = genericShow +instance Show Method where + show = genericShow methodToString :: Method -> String methodToString GET = "GET" diff --git a/src/Axon.Request.Parts.Body.purs b/src/Axon.Request.Parts.Body.purs index 582d6c8..7348996 100644 --- a/src/Axon.Request.Parts.Body.purs +++ b/src/Axon.Request.Parts.Body.purs @@ -7,6 +7,7 @@ import Data.Newtype (class Newtype) import Node.Stream as Stream newtype Json a = Json a + derive instance Generic (Json a) _ derive instance Newtype (Json a) _ derive newtype instance (Eq a) => Eq (Json a) @@ -14,5 +15,6 @@ derive newtype instance (Ord a) => Ord (Json a) derive newtype instance (Show a) => Show (Json a) newtype Stream = Stream (Stream.Readable ()) + derive instance Generic Stream _ derive instance Newtype Stream _ diff --git a/src/Axon.Request.Parts.Class.purs b/src/Axon.Request.Parts.Class.purs index 3c4132f..a904384 100644 --- a/src/Axon.Request.Parts.Class.purs +++ b/src/Axon.Request.Parts.Class.purs @@ -2,6 +2,18 @@ module Axon.Request.Parts.Class (class RequestParts, extractRequestParts, module import Prelude +import Axon.Request (Request) +import Axon.Request as Request +import Axon.Request.Method (Method) +import Axon.Request.Method as Method +import Axon.Request.Parts.Body (Json(..), Stream(..)) +import Axon.Request.Parts.Body (Json(..), Stream(..)) as Parts.Body +import Axon.Request.Parts.Method (Connect, Delete, Get, Options, Patch, Post, Put, Trace) +import Axon.Request.Parts.Method (Get(..), Post(..), Put(..), Patch(..), Delete(..), Trace(..), Options(..), Connect(..)) as Parts.Method +import Axon.Request.Parts.Path (Path(..)) as Path.Parts +import Axon.Request.Parts.Path (class DiscardTupledUnits, class PathParts, Path(..), discardTupledUnits, extractPathParts) +import Axon.Response (Response) +import Axon.Response as Response import Control.Alternative (guard) import Control.Monad.Except (ExceptT(..), runExceptT) import Control.Monad.Maybe.Trans (MaybeT(..), runMaybeT) @@ -17,18 +29,6 @@ import Data.URL as URL import Effect.Aff (Aff) import Effect.Class (liftEffect) import Node.Buffer (Buffer) -import Axon.Request (Request) -import Axon.Request as Request -import Axon.Request.Method (Method) -import Axon.Request.Method as Method -import Axon.Request.Parts.Body (Json(..), Stream(..)) -import Axon.Request.Parts.Body (Json(..), Stream(..)) as Parts.Body -import Axon.Request.Parts.Method (Connect, Delete, Get, Options, Patch, Post, Put, Trace) -import Axon.Request.Parts.Method (Get(..), Post(..), Put(..), Patch(..), Delete(..), Trace(..), Options(..), Connect(..)) as Parts.Method -import Axon.Request.Parts.Path (Path(..)) as Path.Parts -import Axon.Request.Parts.Path (class PathParts, Path(..), extractPathParts) -import Axon.Response (Response) -import Axon.Response as Response extractMethod :: forall @t a. RequestParts a => Newtype t a => Method -> Request -> Aff (Either Response (Maybe t)) extractMethod method r = @@ -54,13 +54,19 @@ instance RequestParts Request where instance RequestParts String where extractRequestParts r = Request.bodyString r - <#> lmap (const $ Response.fromStatus 500) - # ExceptT - # lift - # runMaybeT - # runExceptT + <#> lmap (const $ Response.fromStatus 500) + # ExceptT + # lift + # runMaybeT + # runExceptT -instance PathParts a b => RequestParts (Path a b) where +instance RequestParts (Either Request.BodyStringError String) where + extractRequestParts r = + Request.bodyString r + <#> Just + <#> Right + +instance (PathParts a b, DiscardTupledUnits b c) => RequestParts (Path a c) where extractRequestParts r = let segments = Request.url r # URL.path # case _ of @@ -75,6 +81,7 @@ instance PathParts a b => RequestParts (Path a b) where # Right # MaybeT >>= ensureConsumed + <#> discardTupledUnits <#> Path # runMaybeT # pure diff --git a/src/Axon.Request.Parts.Method.purs b/src/Axon.Request.Parts.Method.purs index a3d60a2..bd65745 100644 --- a/src/Axon.Request.Parts.Method.purs +++ b/src/Axon.Request.Parts.Method.purs @@ -6,6 +6,7 @@ import Data.Generic.Rep (class Generic) import Data.Newtype (class Newtype) newtype Get a = Get a + derive instance Generic (Get a) _ derive instance Newtype (Get a) _ derive newtype instance (Eq a) => Eq (Get a) @@ -13,6 +14,7 @@ derive newtype instance (Ord a) => Ord (Get a) derive newtype instance (Show a) => Show (Get a) newtype Post a = Post a + derive instance Generic (Post a) _ derive instance Newtype (Post a) _ derive newtype instance (Eq a) => Eq (Post a) @@ -20,6 +22,7 @@ derive newtype instance (Ord a) => Ord (Post a) derive newtype instance (Show a) => Show (Post a) newtype Put a = Put a + derive instance Generic (Put a) _ derive instance Newtype (Put a) _ derive newtype instance (Eq a) => Eq (Put a) @@ -27,6 +30,7 @@ derive newtype instance (Ord a) => Ord (Put a) derive newtype instance (Show a) => Show (Put a) newtype Patch a = Patch a + derive instance Generic (Patch a) _ derive instance Newtype (Patch a) _ derive newtype instance (Eq a) => Eq (Patch a) @@ -34,6 +38,7 @@ derive newtype instance (Ord a) => Ord (Patch a) derive newtype instance (Show a) => Show (Patch a) newtype Delete a = Delete a + derive instance Generic (Delete a) _ derive instance Newtype (Delete a) _ derive newtype instance (Eq a) => Eq (Delete a) @@ -41,6 +46,7 @@ derive newtype instance (Ord a) => Ord (Delete a) derive newtype instance (Show a) => Show (Delete a) newtype Options a = Options a + derive instance Generic (Options a) _ derive instance Newtype (Options a) _ derive newtype instance (Eq a) => Eq (Options a) @@ -48,6 +54,7 @@ derive newtype instance (Ord a) => Ord (Options a) derive newtype instance (Show a) => Show (Options a) newtype Trace a = Trace a + derive instance Generic (Trace a) _ derive instance Newtype (Trace a) _ derive newtype instance (Eq a) => Eq (Trace a) @@ -55,6 +62,7 @@ derive newtype instance (Ord a) => Ord (Trace a) derive newtype instance (Show a) => Show (Trace a) newtype Connect a = Connect a + derive instance Generic (Connect a) _ derive instance Newtype (Connect a) _ derive newtype instance (Eq a) => Eq (Connect a) diff --git a/src/Axon.Request.Parts.Path.purs b/src/Axon.Request.Parts.Path.purs index 54016f5..15b1c44 100644 --- a/src/Axon.Request.Parts.Path.purs +++ b/src/Axon.Request.Parts.Path.purs @@ -4,26 +4,46 @@ import Prelude import Control.Alternative (guard) import Data.Array as Array +import Data.Generic.Rep (class Generic) import Data.Int as Int import Data.Maybe (Maybe(..), fromMaybe) +import Data.Show.Generic (genericShow) import Data.Symbol (class IsSymbol, reflectSymbol) import Data.Tuple.Nested (type (/\), (/\)) import Data.URL (URL) import Type.Prelude (Proxy(..)) -newtype Path :: Type -> Type -> Type -newtype Path a b = Path b +data Path :: forall k. k -> Type -> Type +data Path a b = Path b -data Sep :: Type -> Type -> Type +derive instance Generic (Path a b) _ +derive instance Eq b => Eq (Path a b) +instance Show b => Show (Path a b) where + show = genericShow + +data Sep :: forall ka kb. ka -> kb -> Type data Sep a b data IgnoreRest :: Type data IgnoreRest -infixl 9 type Sep as / +infixr 9 type Sep as / infixl 9 type IgnoreRest as ... -class PathParts :: forall a. a -> Type -> Constraint +class DiscardTupledUnits :: Type -> Type -> Constraint +class DiscardTupledUnits a b | a -> b where + discardTupledUnits :: a -> b + +instance (DiscardTupledUnits a b) => DiscardTupledUnits (Unit /\ a) b where + discardTupledUnits (_ /\ a) = discardTupledUnits a +else instance (DiscardTupledUnits a b) => DiscardTupledUnits (a /\ Unit) b where + discardTupledUnits (a /\ _) = discardTupledUnits a +else instance (DiscardTupledUnits aa ab, DiscardTupledUnits ba bb) => DiscardTupledUnits (aa /\ ba) (ab /\ bb) where + discardTupledUnits (a /\ b) = discardTupledUnits a /\ discardTupledUnits b +else instance DiscardTupledUnits a a where + discardTupledUnits = identity + +class PathParts :: forall k. k -> Type -> Constraint class PathParts a b | a -> b where extractPathParts :: URL -> Array String -> Maybe (Array String /\ b) @@ -32,7 +52,7 @@ instance (PathParts aa ab, PathParts ba bb) => PathParts (aa / ba) (ab /\ bb) wh segments' /\ ab <- extractPathParts @aa u segments segments'' /\ bb <- extractPathParts @ba u segments' pure $ segments'' /\ ab /\ bb -else instance PathParts (...) Unit where +else instance PathParts IgnoreRest Unit where extractPathParts _ _ = Just $ [] /\ unit else instance PathParts String String where extractPathParts _ segments = do diff --git a/src/Axon.Request.purs b/src/Axon.Request.purs index 6876e88..350d922 100644 --- a/src/Axon.Request.purs +++ b/src/Axon.Request.purs @@ -1,7 +1,8 @@ -module Axon.Request (Request, BodyReadableError(..), BodyStringError(..), BodyJSONError(..), BodyBufferError(..), bodyReadable, bodyString, bodyJSON, bodyBuffer, headers, method, address, url, contentType, accept, contentLength, lookupHeader) where +module Axon.Request (Request, Body(..), BodyReadableError(..), BodyStringError(..), BodyJSONError(..), BodyBufferError(..), bodyReadable, bodyString, bodyJSON, bodyBuffer, headers, method, address, url, contentType, accept, contentLength, lookupHeader, make) where import Prelude +import Axon.Request.Method (Method) import Control.Monad.Error.Class (throwError, try) import Control.Monad.Except (ExceptT(..), runExceptT) import Control.Monad.Trans.Class (lift) @@ -9,14 +10,15 @@ import Data.Argonaut.Core (Json) import Data.Argonaut.Core (stringify) as JSON import Data.Argonaut.Parser (jsonParser) as JSON import Data.Bifunctor (lmap) -import Data.Either (Either, note) +import Data.Either (Either) +import Data.FoldableWithIndex (foldlWithIndex) import Data.Generic.Rep (class Generic) import Data.Int as Int import Data.MIME (MIME) import Data.MIME as MIME import Data.Map (Map) import Data.Map as Map -import Data.Maybe (Maybe(..)) +import Data.Maybe (Maybe) import Data.Show.Generic (genericShow) import Data.String.Lower (StringLower) import Data.String.Lower as String.Lower @@ -35,7 +37,6 @@ import Node.Encoding (Encoding(..)) import Node.Net.Types (IPv4, IPv6, SocketAddress) import Node.Stream as Stream import Node.Stream.Aff as Stream.Aff -import Axon.Request.Method (Method) data BodyReadableError = BodyReadableErrorHasBeenConsumed @@ -55,6 +56,7 @@ instance Eq BodyBufferError where eq (BodyBufferErrorReadable a) (BodyBufferErrorReadable b) = a == b eq (BodyBufferErrorReading a) (BodyBufferErrorReading b) = Error.message a == Error.message b eq _ _ = false + instance Show BodyBufferError where show = genericShow @@ -93,6 +95,18 @@ data Request = , bodyRef :: Effect.Ref Body } +make + :: { headers :: Map String String + , address :: Either (SocketAddress IPv4) (SocketAddress IPv6) + , url :: URL + , method :: Method + , body :: Body + } + -> Effect Request +make a = do + bodyRef <- Ref.new a.body + pure $ Request { bodyRef: bodyRef, headers: foldlWithIndex (\k m v -> Map.insert (String.Lower.fromString k) v m) Map.empty a.headers, address: a.address, url: a.url, method: a.method } + headers :: Request -> Map StringLower String headers (Request a) = a.headers @@ -118,7 +132,7 @@ url :: Request -> URL url (Request a) = a.url bodyReadable :: Request -> Effect (Either BodyReadableError (Stream.Readable ())) -bodyReadable (Request {bodyRef}) = runExceptT do +bodyReadable (Request { bodyRef }) = runExceptT do body <- liftEffect $ Ref.read bodyRef case body of BodyEmpty -> throwError BodyReadableErrorEmpty @@ -130,38 +144,38 @@ bodyReadable (Request {bodyRef}) = runExceptT do BodyCachedJSON json -> json # JSON.stringify # flip Buffer.fromString UTF8 >>= Stream.readableFromBuffer # lift bodyBuffer :: Request -> Aff (Either BodyBufferError Buffer) -bodyBuffer r@(Request {bodyRef}) = +bodyBuffer r@(Request { bodyRef }) = let stream = - bodyReadable r - # liftEffect - <#> lmap BodyBufferErrorReadable - # ExceptT + bodyReadable r + # liftEffect + <#> lmap BodyBufferErrorReadable + # ExceptT readAll s = Stream.Aff.readAll s - # liftAff - # try - <#> lmap BodyBufferErrorReading - # ExceptT - >>= (liftEffect <<< Buffer.concat) + # liftAff + # try + <#> lmap BodyBufferErrorReading + # ExceptT + >>= (liftEffect <<< Buffer.concat) in - runExceptT do - body <- Ref.read bodyRef # liftEffect - case body of - BodyCached buf -> pure buf - BodyCachedString str -> Buffer.fromString str UTF8 # liftEffect - BodyCachedJSON json -> Buffer.fromString (JSON.stringify json) UTF8 # liftEffect - _ -> do - buf <- stream >>= readAll - Ref.write (BodyCached buf) bodyRef $> buf # liftEffect - + runExceptT do + body <- Ref.read bodyRef # liftEffect + case body of + BodyCached buf -> pure buf + BodyCachedString str -> Buffer.fromString str UTF8 # liftEffect + BodyCachedJSON json -> Buffer.fromString (JSON.stringify json) UTF8 # liftEffect + _ -> do + buf <- stream >>= readAll + Ref.write (BodyCached buf) bodyRef $> buf # liftEffect + bodyString :: Request -> Aff (Either BodyStringError String) -bodyString r@(Request {bodyRef}) = +bodyString r@(Request { bodyRef }) = let buf = - bodyBuffer r - <#> lmap BodyStringErrorBuffer - # ExceptT + bodyBuffer r + <#> lmap BodyStringErrorBuffer + # ExceptT bufString b = Buffer.toString UTF8 b # liftEffect @@ -169,32 +183,32 @@ bodyString r@(Request {bodyRef}) = <#> lmap (const BodyStringErrorNotUTF8) # ExceptT in - runExceptT do - body <- Ref.read bodyRef # liftEffect - case body of - BodyCachedString str -> pure str - BodyCachedJSON json -> JSON.stringify json # pure - _ -> do - str <- buf >>= bufString - Ref.write (BodyCachedString str) bodyRef $> str # liftEffect + runExceptT do + body <- Ref.read bodyRef # liftEffect + case body of + BodyCachedString str -> pure str + BodyCachedJSON json -> JSON.stringify json # pure + _ -> do + str <- buf >>= bufString + Ref.write (BodyCachedString str) bodyRef $> str # liftEffect bodyJSON :: Request -> Aff (Either BodyJSONError Json) -bodyJSON r@(Request {bodyRef}) = +bodyJSON r@(Request { bodyRef }) = let str = - bodyString r - <#> lmap BodyJSONErrorString - # ExceptT + bodyString r + <#> lmap BodyJSONErrorString + # ExceptT parse s = JSON.jsonParser s - # lmap BodyJSONErrorParsing - # pure - # ExceptT + # lmap BodyJSONErrorParsing + # pure + # ExceptT in - runExceptT do - body <- Ref.read bodyRef # liftEffect - case body of - BodyCachedJSON j -> pure j - _ -> do - j <- str >>= parse - Ref.write (BodyCachedJSON j) bodyRef $> j # liftEffect + runExceptT do + body <- Ref.read bodyRef # liftEffect + case body of + BodyCachedJSON j -> pure j + _ -> do + j <- str >>= parse + Ref.write (BodyCachedJSON j) bodyRef $> j # liftEffect diff --git a/src/Axon.Response.Body.purs b/src/Axon.Response.Body.purs index 4701ff6..f6e36c4 100644 --- a/src/Axon.Response.Body.purs +++ b/src/Axon.Response.Body.purs @@ -16,6 +16,13 @@ data Body | BodyFormData HTTP.RawFormData | BodyReadable (Stream.Readable ()) +instance Show Body where + show BodyEmpty = "BodyEmpty" + show (BodyString s) = "BodyString " <> show s + show (BodyBuffer _) = "BodyBuffer _" + show (BodyFormData _) = "BodyFormData _" + show (BodyReadable _) = "BodyReadable _" + formBody :: HTTP.Form -> Effect Body formBody f = HTTP.Form.toRawFormData f <#> BodyFormData diff --git a/src/Axon.Response.purs b/src/Axon.Response.purs index b3cfeef..253dcfd 100644 --- a/src/Axon.Response.purs +++ b/src/Axon.Response.purs @@ -1,19 +1,25 @@ -module Axon.Response (Response, response, body, status, headers, withHeader, withBody, withStatus, fromStatus, ok, module Body) where +module Axon.Response (Response(..), response, body, status, headers, withHeader, withBody, withStatus, fromStatus, ok, module Body) where import Prelude -import Data.FoldableWithIndex (foldlWithIndex) -import Data.Map (Map) -import Data.Map as Map -import Data.String.Lower (StringLower) -import Data.String.Lower as String.Lower import Axon.Response.Body (Body(..)) import Axon.Response.Body (Body(..), formBody) as Body +import Data.FoldableWithIndex (foldlWithIndex) +import Data.Generic.Rep (class Generic) +import Data.Map (Map) +import Data.Map as Map +import Data.Show.Generic (genericShow) +import Data.String.Lower (StringLower) +import Data.String.Lower as String.Lower -data Response = Response {body :: Body, headers :: Map StringLower String, status :: Int} +data Response = Response { body :: Body, headers :: Map StringLower String, status :: Int } + +derive instance Generic Response _ +instance Show Response where + show = genericShow response :: Int -> Body -> Map String String -> Response -response s b h = Response {status: s, body: b, headers: h # foldlWithIndex (\k m v -> Map.insert (String.Lower.fromString k) v m) Map.empty} +response s b h = Response { status: s, body: b, headers: h # foldlWithIndex (\k m v -> Map.insert (String.Lower.fromString k) v m) Map.empty } status :: Response -> Int status (Response a) = a.status @@ -25,16 +31,16 @@ headers :: Response -> Map StringLower String headers (Response a) = a.headers withHeader :: String -> String -> Response -> Response -withHeader k v (Response a) = Response $ a {headers = Map.insert (String.Lower.fromString k) v a.headers} +withHeader k v (Response a) = Response $ a { headers = Map.insert (String.Lower.fromString k) v a.headers } withStatus :: Int -> Response -> Response -withStatus s (Response a) = Response $ a {status = s} +withStatus s (Response a) = Response $ a { status = s } withBody :: Body -> Response -> Response -withBody b (Response a) = Response $ a {body = b} +withBody b (Response a) = Response $ a { body = b } fromStatus :: Int -> Response -fromStatus s = Response {body: BodyEmpty, headers: Map.empty, status: s} +fromStatus s = Response { body: BodyEmpty, headers: Map.empty, status: s } ok :: Response ok = fromStatus 200 diff --git a/src/Axon.Web.Headers.js b/src/Axon.Web.Headers.js index 7814dc8..82aaf6e 100644 --- a/src/Axon.Web.Headers.js +++ b/src/Axon.Web.Headers.js @@ -2,4 +2,8 @@ /// /** @type {(_: {tuple: (a: A) => (b: B) => unknown}) => (h: Headers) => () => Array} */ -export const headerEntries = ({tuple}) => hs => () => Array.from(hs.entries()).map(([a, b]) => tuple(a)(b)) +export const headerEntries = + ({ tuple }) => + hs => + () => + Array.from(hs.entries()).map(([a, b]) => tuple(a)(b)) diff --git a/src/Axon.Web.Headers.purs b/src/Axon.Web.Headers.purs index 76ec6bc..8778a8a 100644 --- a/src/Axon.Web.Headers.purs +++ b/src/Axon.Web.Headers.purs @@ -4,4 +4,4 @@ import Data.Tuple.Nested (type (/\)) import Effect (Effect) foreign import data WebHeaders :: Type -foreign import headerEntries :: {tuple :: forall a b. a -> b -> a /\ b} -> WebHeaders -> Effect (Array (String /\ String)) +foreign import headerEntries :: { tuple :: forall a b. a -> b -> a /\ b } -> WebHeaders -> Effect (Array (String /\ String)) diff --git a/src/Axon.Web.Request.js b/src/Axon.Web.Request.js index d9abfeb..9a87633 100644 --- a/src/Axon.Web.Request.js +++ b/src/Axon.Web.Request.js @@ -20,15 +20,15 @@ export const headers = r => () => r.headers /** @type {(r: ReadableStream) => () => Stream.Readable} */ export const readableFromWeb = r => () => { - const reader = r.getReader(); + const reader = r.getReader() return new Stream.Readable({ - read: function() { - (async () => { + read: function () { + ;(async () => { /** @type {ReadableStreamReadResult | undefined} */ - let res = undefined; + let res = undefined try { res = await reader.read() - } catch(e) { + } catch (e) { if (typeof e === 'undefined' || e instanceof Error) { this.destroy(e) return @@ -37,8 +37,8 @@ export const readableFromWeb = r => () => { } } - if (res.value) this.push(res.value); - if (res.done) this.push(null); + if (res.value) this.push(res.value) + if (res.done) this.push(null) })() }, }) diff --git a/src/Axon.Web.Request.purs b/src/Axon.Web.Request.purs index 4a2f659..c875f0e 100644 --- a/src/Axon.Web.Request.purs +++ b/src/Axon.Web.Request.purs @@ -1,10 +1,10 @@ module Axon.Web.Request where import Data.ArrayBuffer.Types (Uint8Array) +import Axon.Web.Headers (WebHeaders) import Data.Nullable (Nullable) import Effect (Effect) import Node.Stream as Stream -import Axon.Request.Web (WebHeaders) import Web.Streams.ReadableStream (ReadableStream) foreign import data WebRequest :: Type diff --git a/src/Main.purs b/src/Main.purs deleted file mode 100644 index 5c18dca..0000000 --- a/src/Main.purs +++ /dev/null @@ -1,10 +0,0 @@ -module Main where - -import Prelude - -import Effect (Effect) -import Effect.Console (log) - -main :: Effect Unit -main = do - log "🍝" diff --git a/test/Test/Axon.Request.Parts.purs b/test/Test/Axon.Request.Parts.purs new file mode 100644 index 0000000..386360f --- /dev/null +++ b/test/Test/Axon.Request.Parts.purs @@ -0,0 +1,123 @@ +module Test.Axon.Request.Parts where + +import Prelude + +import Axon.Request (Request) +import Axon.Request as Request +import Axon.Request.Method (Method(..)) +import Axon.Request.Parts.Class (Json(..), Patch(..), Path(..), Post(..), extractRequestParts) +import Axon.Request.Parts.Path (type (...), type (/), IgnoreRest) +import Control.Monad.Error.Class (liftEither, liftMaybe) +import Data.Bifunctor (lmap) +import Data.Either (Either(..)) +import Data.Map as Map +import Data.Maybe (Maybe(..), fromJust) +import Data.Tuple.Nested (type (/\), (/\)) +import Data.URL as URL +import Effect.Class (liftEffect) +import Effect.Exception (error) +import Effect.Unsafe (unsafePerformEffect) +import Node.Buffer as Buffer +import Node.Encoding (Encoding(..)) +import Node.Net.SocketAddress as SocketAddress +import Node.Stream as Stream +import Partial.Unsafe (unsafePartial) +import Test.Spec (Spec, describe, it) +import Test.Spec.Assertions (shouldEqual) + +spec :: Spec Unit +spec = describe "Parts" do + it "extracts the whole request" do + req <- liftEffect $ Request.make {body: Request.BodyEmpty, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust, headers: Map.empty, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81}, method: GET} + void $ extractRequestParts @Request req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + + it "extracts method, path, JSON body" do + stream <- Buffer.fromString """{"firstName": "henry"}""" UTF8 >>= Stream.readableFromBuffer # liftEffect + req <- liftEffect $ Request.make {body: Request.BodyReadable stream, url: URL.fromString "http://localhost:80/users/12" # unsafePartial fromJust, headers: Map.empty, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81}, method: PATCH} + a <- extractRequestParts @(Patch ((Path ("users" / Int) _) /\ Json {firstName :: String})) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` Patch (Path 12 /\ Json {firstName: "henry"}) + + describe "Path" do + it "matches a route matching literal" do + req <- liftEffect $ Request.make {body: Request.BodyCachedString "foo", url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust, headers: Map.empty, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81}, method: GET} + a <- extractRequestParts @(Path "foo" _) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` (Path unit) + + it "matches a route matching multiple literals" do + req <- liftEffect $ Request.make {body: Request.BodyCachedString "foo", url: URL.fromString "http://localhost:80/foo/bar/baz" # unsafePartial fromJust, headers: Map.empty, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81}, method: GET} + a <- extractRequestParts @(Path ("foo" / "bar" / "baz") _) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` (Path unit) + + it "does not partially match a route ..." do + req <- liftEffect $ Request.make + { body: Request.BodyCachedString "foo" + , url: URL.fromString "http://localhost:80/foo/bar/baz" # unsafePartial fromJust + , headers: Map.empty + , address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81} + , method: GET + } + a <- extractRequestParts @(Path ("foo" / "bar") _) req <#> lmap (error <<< show) >>= liftEither + a `shouldEqual` Nothing + + it "... but does if ends in IgnoreRest" do + req <- liftEffect $ Request.make + { body: Request.BodyCachedString "foo" + , url: URL.fromString "http://localhost:80/foo/bar/baz" # unsafePartial fromJust + , headers: Map.empty + , address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81} + , method: GET + } + a <- extractRequestParts @(Path ("foo" / "bar" / IgnoreRest) _) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` (Path unit) + + it "extracts an int" do + req <- liftEffect $ Request.make + { body: Request.BodyCachedString "foo" + , url: URL.fromString "http://localhost:80/foo/123/bar" # unsafePartial fromJust + , headers: Map.empty + , address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81} + , method: GET + } + a <- extractRequestParts @(Path ("foo" / Int / "bar") _) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` (Path 123) + + it "extracts an int and a string" do + req <- liftEffect $ Request.make + { body: Request.BodyCachedString "foo" + , url: URL.fromString "http://localhost:80/foo/123/bar/baz" # unsafePartial fromJust + , headers: Map.empty + , address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81} + , method: GET + } + a <- extractRequestParts @(Path ("foo" / Int / "bar" / String) _) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` (Path $ 123 /\ "baz") + + describe "Body" do + it "extracts a string body from a cached string" do + req <- liftEffect $ Request.make {body: Request.BodyCachedString "foo", url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust, headers: Map.empty, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81}, method: GET} + a <- extractRequestParts @(Either Request.BodyStringError String) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` (Right "foo") + + it "extracts a string body from a readable stream" do + stream <- Buffer.fromString "foo" UTF8 >>= Stream.readableFromBuffer # liftEffect + req <- liftEffect $ Request.make {body: Request.BodyReadable stream, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust, headers: Map.empty, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81}, method: GET} + a <- extractRequestParts @(Either Request.BodyStringError String) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` (Right "foo") + + a' <- extractRequestParts @String req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a' `shouldEqual` "foo" + + it "extracts a string body from a buffer" do + buf <- Buffer.fromString "foo" UTF8 # liftEffect + req <- liftEffect $ Request.make {body: Request.BodyCached buf, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust, headers: Map.empty, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81}, method: GET} + a <- extractRequestParts @(Either Request.BodyStringError String) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` (Right "foo") + + a' <- extractRequestParts @String req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a' `shouldEqual` "foo" + + it "extracts a JSON body" do + stream <- Buffer.fromString """{"foo": 123, "bar": "abc"}""" UTF8 >>= Stream.readableFromBuffer # liftEffect + req <- liftEffect $ Request.make {body: Request.BodyReadable stream, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust, headers: Map.empty, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4 {address: "127.0.0.1", port: 81}, method: POST} + a <- extractRequestParts @(Post (Json {foo :: Int, bar :: String})) req <#> lmap (error <<< show) >>= liftEither >>= liftMaybe (error "was nothing") + a `shouldEqual` Post (Json {foo: 123, bar: "abc"}) diff --git a/test/Test/Axon.Request.purs b/test/Test/Axon.Request.purs new file mode 100644 index 0000000..e76d77a --- /dev/null +++ b/test/Test/Axon.Request.purs @@ -0,0 +1,10 @@ +module Test.Axon.Request where + +import Prelude + +import Test.Axon.Request.Parts as Parts +import Test.Spec (Spec, describe) + +spec :: Spec Unit +spec = describe "Request" do + Parts.spec diff --git a/test/Test/Main.purs b/test/Test/Main.purs index e616930..b52ba08 100644 --- a/test/Test/Main.purs +++ b/test/Test/Main.purs @@ -3,10 +3,11 @@ module Test.Main where import Prelude import Effect (Effect) -import Effect.Class.Console (log) +import Test.Axon.Request as Test.Request +import Test.Spec (describe) +import Test.Spec.Reporter (specReporter) +import Test.Spec.Runner.Node (runSpecAndExitProcess) main :: Effect Unit -main = do - log "🍕" - log "You should add some tests." - +main = runSpecAndExitProcess [ specReporter ] $ describe "Axon" do + Test.Request.spec