diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a39bce1..d606c03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: run: | bower install npm run-script test --if-present - + - name: Check formatting run: | purs-tidy check src test diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd6f87..2533bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ New features: Bugfixes: +Other improvements: + +## [v7.0.0](https://github.com/purescript-node/purescript-node-url/releases/tag/v7.0.0) - 2023-07-31 + +Breaking changes: +- Drop support for Legacy API (#20 by @JordanMartinez) +- Drop support for `querystring` bindings (#20 by @JordanMartinez) +- Implement WHATWG URL API bindings (#20 by @JordanMartinez) + +New features: +- Implement bindings for `URLSearchParams` (#20 by @JordanMartinez) + +Bugfixes: + Other improvements: - Update CI `node` to `lts/*` (#19 by @JordanMartinez) - Update CI actions to `v3` (#19 by @JordanMartinez) diff --git a/bower.json b/bower.json index 56815b0..4470a66 100644 --- a/bower.json +++ b/bower.json @@ -12,7 +12,11 @@ "url": "https://github.com/purescript-node/purescript-node-url.git" }, "dependencies": { - "purescript-nullable": "^6.0.0" + "purescript-prelude": "^6.0.1", + "purescript-effect": "^4.0.0", + "purescript-foreign": "^7.0.0", + "purescript-nullable": "^6.0.0", + "purescript-tuples": "^7.0.0" }, "devDependencies": { "purescript-assert": "^6.0.0" diff --git a/src/Node/URL.js b/src/Node/URL.js index c8afc32..e669819 100644 --- a/src/Node/URL.js +++ b/src/Node/URL.js @@ -1,10 +1,58 @@ -import url from "url"; -import queryString from "querystring"; -export { parse, format } from "url"; +import url from "node:url"; -export function resolve(from) { - return to => url.resolve(from, to); -} +export const newImpl = (input) => new url.URL(input); +export const newRelativeImpl = (input, base) => new url.URL(input, base); +export const pathToFileURLImpl = (path) => url.pathToFileURL(path); +export const hashImpl = (u) => u.hash; +export const setHashImpl = (h, u) => { + u.hash = h; +}; +export const hostImpl = (url) => url.host; +export const setHostImpl = (val, u) => { + u.host = val; +}; +export const hostnameImpl = (u) => u.hostname; +export const setHostnameImpl = (val, u) => { + u.hostname = val; +}; +export const uneffectfulHref = (u) => u.href; +export const hrefImpl = (u) => u.href; +export const setHrefImpl = (val, u) => { + u.href = val; +}; +export const origin = (u) => u.origin; +export const passwordImpl = (u) => u.password; +export const setPasswordImpl = (val, u) => { + u.password = val; +}; +export const pathnameImpl = (u) => u.pathname; +export const setPathnameImpl = (val, u) => { + u.pathname = val; +}; +export const portImpl = (u) => u.port; +export const setPortImpl = (val, u) => { + u.port = val; +}; +export const protocolImpl = (u) => u.protocol; +export const setProtocolImpl = (val, u) => { + u.protocol = val; +}; +export const searchImpl = (u) => u.search; +export const setSearchImpl = (val, u) => { + u.search = val; +}; +export const searchParamsImpl = (u) => u.searchParams; +export const usernameImpl = (u) => u.username; +export const setUsernameImpl = (val, u) => { + u.username = val; +}; -export const parseQueryString = queryString.parse; -export const toQueryString = queryString.stringify; +export const canParseImpl = (input, base) => url.URL.canParse(input, base); +export const domainToAsciiImpl = (domain) => url.domainToASCII(domain); +export const domainToUnicodeImpl = (domain) => url.domainToUnicode(domain); +export const fileURLToPathImpl = (str) => url.fileURLToPath(str); +export const fileURLToPathUrlImpl = (str) => url.fileURLToPath(str); +export const formatImpl = (theUrl) => url.format(theUrl); +export const formatOptsImpl = (theUrl, opts) => url.format(theUrl, opts); +export const pathToFileUrlImpl = (path) => url.pathToFileURL(path); +export const urlToHttpOptionsImpl = (theUrl) => url.urlToHttpOptions(theUrl); diff --git a/src/Node/URL.purs b/src/Node/URL.purs index 08df4f8..e5bf177 100644 --- a/src/Node/URL.purs +++ b/src/Node/URL.purs @@ -1,44 +1,258 @@ --- | This module defines bindings to the Node URL and Query String APIs. - -module Node.URL where - -import Data.Nullable - --- | A query object is a JavaScript object whose values are strings or arrays of strings. --- | --- | It is intended that the user coerce values of this type to/from some trusted representation via --- | e.g. `Data.Foreign` or `Unsafe.Coerce`.. -data Query - --- | A URL object. --- | --- | All fields are nullable, and will be missing if the URL string passed to --- | `parse` did not contain the appropriate URL part. -type URL = - { protocol :: Nullable String - , slashes :: Nullable Boolean - , host :: Nullable String - , auth :: Nullable String - , hostname :: Nullable String - , port :: Nullable String - , pathname :: Nullable String - , search :: Nullable String - , path :: Nullable String - , query :: Nullable String - , hash :: Nullable String - } +module Node.URL + ( URL + , new + , new' + , pathToFileURL + , hash + , setHash + , host + , setHost + , hostname + , setHostname + , href + , setHref + , origin + , password + , setPassword + , pathname + , setPathname + , port + , setPort + , protocol + , setProtocol + , search + , setSearch + , searchParams + , username + , setUsername + , canParse + , domainToAscii + , domainToUnicode + , fileURLToPath + , fileURLToPath' + , UrlFormatOptions + , format + , format' + , pathToFileUrl + , HttpOptions + , urlToHTTPOptions + ) where + +import Prelude + +import Data.Function.Uncurried (Fn2, runFn2) +import Effect (Effect) +import Effect.Uncurried (EffectFn1, EffectFn2, runEffectFn1, runEffectFn2) +import Node.URL.URLSearchParams (URLSearchParams) +import Prim.Row as Row + +foreign import data URL :: Type + +instance Show URL where + show x = "URL(" <> uneffectfulHref x <> ")" + +new :: String -> Effect URL +new input = runEffectFn1 newImpl input + +foreign import newImpl :: EffectFn1 (String) (URL) + +new' :: String -> String -> Effect URL +new' input base = runEffectFn2 newRelativeImpl input base + +foreign import newRelativeImpl :: EffectFn2 (String) (String) (URL) + +pathToFileURL :: String -> Effect URL +pathToFileURL path = runEffectFn1 pathToFileURLImpl path + +foreign import pathToFileURLImpl :: EffectFn1 (String) (URL) + +hash :: URL -> Effect String +hash url = runEffectFn1 hashImpl url + +foreign import hashImpl :: EffectFn1 URL String + +setHash :: String -> URL -> Effect Unit +setHash val url = runEffectFn2 setHashImpl val url + +foreign import setHashImpl :: EffectFn2 String URL Unit + +host :: URL -> Effect String +host url = runEffectFn1 hostImpl url + +foreign import hostImpl :: EffectFn1 URL String + +setHost :: String -> URL -> Effect Unit +setHost val url = runEffectFn2 setHostImpl val url + +foreign import setHostImpl :: EffectFn2 String URL Unit + +hostname :: URL -> Effect String +hostname url = runEffectFn1 hostnameImpl url + +foreign import hostnameImpl :: EffectFn1 URL String + +setHostname :: String -> URL -> Effect Unit +setHostname val url = runEffectFn2 setHostnameImpl val url + +foreign import setHostnameImpl :: EffectFn2 String URL Unit + +foreign import uneffectfulHref :: URL -> String + +href :: URL -> Effect String +href url = runEffectFn1 hrefImpl url + +foreign import hrefImpl :: EffectFn1 URL String + +setHref :: String -> URL -> Effect Unit +setHref val url = runEffectFn2 setHrefImpl val url + +foreign import setHrefImpl :: EffectFn2 String URL Unit + +foreign import origin :: URL -> String + +password :: URL -> Effect String +password url = runEffectFn1 passwordImpl url + +foreign import passwordImpl :: EffectFn1 URL String + +setPassword :: String -> URL -> Effect Unit +setPassword val url = runEffectFn2 setPasswordImpl val url + +foreign import setPasswordImpl :: EffectFn2 String URL Unit + +pathname :: URL -> Effect String +pathname url = runEffectFn1 pathnameImpl url + +foreign import pathnameImpl :: EffectFn1 URL String + +setPathname :: String -> URL -> Effect Unit +setPathname val url = runEffectFn2 setPathnameImpl val url --- | Parse a URL string into a URL object. -foreign import parse :: String -> URL +foreign import setPathnameImpl :: EffectFn2 String URL Unit --- | Format a URL object as a URL string. -foreign import format :: URL -> String +port :: URL -> Effect String +port url = runEffectFn1 portImpl url --- | Resolve a URL relative to some base URL. -foreign import resolve :: String -> String -> String +foreign import portImpl :: EffectFn1 URL String + +setPort :: String -> URL -> Effect Unit +setPort val url = runEffectFn2 setPortImpl val url + +foreign import setPortImpl :: EffectFn2 String URL Unit + +protocol :: URL -> Effect String +protocol url = runEffectFn1 protocolImpl url + +foreign import protocolImpl :: EffectFn1 URL String + +setProtocol :: String -> URL -> Effect Unit +setProtocol val url = runEffectFn2 setProtocolImpl val url + +foreign import setProtocolImpl :: EffectFn2 String URL Unit + +search :: URL -> Effect String +search url = runEffectFn1 searchImpl url + +foreign import searchImpl :: EffectFn1 URL String + +setSearch :: String -> URL -> Effect Unit +setSearch val url = runEffectFn2 setSearchImpl val url + +foreign import setSearchImpl :: EffectFn2 String URL Unit + +searchParams :: URL -> Effect URLSearchParams +searchParams url = runEffectFn1 searchParamsImpl url + +foreign import searchParamsImpl :: EffectFn1 (URL) (URLSearchParams) + +username :: URL -> Effect String +username url = runEffectFn1 usernameImpl url + +foreign import usernameImpl :: EffectFn1 URL String + +setUsername :: String -> URL -> Effect Unit +setUsername val url = runEffectFn2 setUsernameImpl val url + +foreign import setUsernameImpl :: EffectFn2 String URL Unit + +canParse :: String -> String -> Boolean +canParse input base = runFn2 canParseImpl input base + +foreign import canParseImpl :: Fn2 (String) (String) (Boolean) + +domainToAscii :: String -> Effect String +domainToAscii domain = runEffectFn1 domainToAsciiImpl domain + +foreign import domainToAsciiImpl :: EffectFn1 (String) (String) + +domainToUnicode :: String -> Effect String +domainToUnicode domain = runEffectFn1 domainToUnicodeImpl domain + +foreign import domainToUnicodeImpl :: EffectFn1 (String) (String) + +fileURLToPath :: String -> Effect String +fileURLToPath str = runEffectFn1 fileURLToPathImpl str + +foreign import fileURLToPathImpl :: EffectFn1 String String + +fileURLToPath' :: URL -> Effect String +fileURLToPath' url = runEffectFn1 fileURLToPathUrlImpl url + +foreign import fileURLToPathUrlImpl :: EffectFn1 URL String + +format :: URL -> Effect String +format url = runEffectFn1 formatImpl url + +foreign import formatImpl :: EffectFn1 (URL) (String) + +-- | - `auth` true if the serialized URL string should include the username and password, false otherwise. Default: true. +-- | - `fragment` true if the serialized URL string should include the fragment, false otherwise. Default: true. +-- | - `search` true if the serialized URL string should include the search query, false otherwise. Default: true. +-- | - `unicode` true if Unicode characters appearing in the host component of the URL string should be encoded directly as opposed to being Punycode encoded. Default: false. +type UrlFormatOptions = + ( auth :: Boolean + , fragment :: Boolean + , search :: Boolean + , unicode :: Boolean + ) + +format' + :: forall r trash + . Row.Union r trash UrlFormatOptions + => URL + -> { | r } + -> Effect String +format' url opts = runEffectFn2 formatOptsImpl url opts + +foreign import formatOptsImpl :: forall r. EffectFn2 (URL) ({ | r }) (String) + +pathToFileUrl :: String -> Effect URL +pathToFileUrl path = runEffectFn1 pathToFileUrlImpl path + +foreign import pathToFileUrlImpl :: EffectFn1 (String) (URL) + +-- | - `protocol` Protocol to use. +-- | - `hostname` A domain name or IP address of the server to issue the request to. +-- | - `hash` The fragment portion of the URL. +-- | - `search` The serialized query portion of the URL. +-- | - `pathname` The path portion of the URL. +-- | - `path` Request path. Should include query string if any. E.G. '/index.html?page=12'. An exception is thrown when the request path contains illegal characters. Currently, only spaces are rejected but that may change in the future. +-- | - `href` The serialized URL. +-- | - `port` Port of remote server. +-- | - `auth` Basic authentication i.e. 'user:password' to compute an Authorization header. +type HttpOptions = + { protocol :: String + , hostname :: String + , hash :: String + , search :: String + , pathname :: String + , path :: String + , href :: String + , port :: Int + , auth :: String + } --- | Convert a query string to an object. -foreign import parseQueryString :: String -> Query +urlToHTTPOptions :: URL -> Effect HttpOptions +urlToHTTPOptions url = runEffectFn1 urlToHttpOptionsImpl url --- | Convert a query string to an object. -foreign import toQueryString :: Query -> String +foreign import urlToHttpOptionsImpl :: EffectFn1 (URL) (HttpOptions) diff --git a/src/Node/URL/URLSearchParams.js b/src/Node/URL/URLSearchParams.js new file mode 100644 index 0000000..3c185cd --- /dev/null +++ b/src/Node/URL/URLSearchParams.js @@ -0,0 +1,28 @@ +import url from "node:url"; + +const newImpl = () => new url.URLSearchParams(); +export { newImpl as new }; +export const fromStringImpl = (str) => new url.URLSearchParams(str); +export const fromObjectImpl = (obj) => new url.URLSearchParams(obj); +export const appendParamImpl = (name, value, params) => params.append(name, value); +export const deleteImpl = (name, params) => params.delete(name); +export const getNameImpl = (name, params) => params.getName(name); +export const getAllImpl = (name, params) => params.getAll(name); +export const hasImpl = (name, params) => params.has(name); +export const setImpl = (name, value, params) => params.set(name, value); +export const sizeImpl = (params) => params.size; +export const sortImpl = (params) => params.sort(); +export const toStringImpl = (params) => params.toString(); +export const entriesImpl = (params, tuple) => { + const arr = new Array(params.size); + params.forEach((value, name) => { + arr.push(tuple(name, value)); + }); + return arr; +}; +export const keysImpl = (params) => { + Array.from(params.keys()); +}; +export const valuesImpl = (params) => { + Array.from(params.values()); +}; diff --git a/src/Node/URL/URLSearchParams.purs b/src/Node/URL/URLSearchParams.purs new file mode 100644 index 0000000..4dc7d1f --- /dev/null +++ b/src/Node/URL/URLSearchParams.purs @@ -0,0 +1,108 @@ +module Node.URL.URLSearchParams + ( URLSearchParams + , new + , fromString + , fromObject + , appendParam + , delete + , getName + , getAll + , has + , set + , size + , sort + , toString + , entries + , keys + , values + ) where + +import Prelude + +import Data.Maybe (Maybe) +import Data.Nullable (Nullable, toMaybe) +import Data.Tuple (Tuple(..)) +import Effect (Effect) +import Effect.Uncurried (EffectFn1, EffectFn2, EffectFn3, runEffectFn1, runEffectFn2, runEffectFn3) +import Foreign (Foreign) + +foreign import data URLSearchParams :: Type + +foreign import new :: Effect (URLSearchParams) + +fromString :: String -> Effect URLSearchParams +fromString str = runEffectFn1 fromStringImpl str + +foreign import fromStringImpl :: EffectFn1 (String) (URLSearchParams) + +-- | Each label in the record arg must be of type `String`, `Array String`, +-- | or a newtyped version of those two types. +-- | Note: the compiler does not check the record arg to verify that this constraint +-- | has been satisfied. +fromObject + :: forall r + . { | r } + -> Effect URLSearchParams +fromObject obj = runEffectFn1 fromObjectImpl obj + +foreign import fromObjectImpl :: forall r. EffectFn1 ({ | r }) (URLSearchParams) + +appendParam :: String -> String -> URLSearchParams -> Effect Unit +appendParam name value params = runEffectFn3 appendParamImpl name value params + +foreign import appendParamImpl :: EffectFn3 (String) (String) (URLSearchParams) (Unit) + +delete :: String -> URLSearchParams -> Effect Unit +delete name params = runEffectFn2 deleteImpl name params + +foreign import deleteImpl :: EffectFn2 (String) (URLSearchParams) (Unit) + +getName :: String -> URLSearchParams -> Effect (Maybe String) +getName name params = map toMaybe $ runEffectFn2 getNameImpl name params + +foreign import getNameImpl :: EffectFn2 (String) (URLSearchParams) (Nullable String) + +getAll :: String -> URLSearchParams -> Effect (Array String) +getAll name params = runEffectFn2 getAllImpl name params + +foreign import getAllImpl :: EffectFn2 (String) (URLSearchParams) (Array String) + +has :: String -> URLSearchParams -> Effect Boolean +has name params = runEffectFn2 hasImpl name params + +foreign import hasImpl :: EffectFn2 (String) (URLSearchParams) (Boolean) + +set :: String -> String -> URLSearchParams -> Effect Unit +set name value params = runEffectFn3 setImpl name value params + +foreign import setImpl :: EffectFn3 (String) (String) (URLSearchParams) (Unit) + +size :: URLSearchParams -> Effect Int +size params = runEffectFn1 sizeImpl params + +foreign import sizeImpl :: EffectFn1 (URLSearchParams) (Int) + +sort :: URLSearchParams -> Effect Unit +sort params = runEffectFn1 sortImpl params + +foreign import sortImpl :: EffectFn1 (URLSearchParams) (Unit) + +toString :: URLSearchParams -> Effect String +toString params = runEffectFn1 toStringImpl params + +foreign import toStringImpl :: EffectFn1 (URLSearchParams) (String) + +entries :: URLSearchParams -> Effect (Array (Tuple String Foreign)) +entries params = runEffectFn2 entriesImpl params Tuple + +foreign import entriesImpl :: forall a b. EffectFn2 URLSearchParams (a -> b -> Tuple a b) (Array (Tuple String Foreign)) + +keys :: URLSearchParams -> Effect (Array String) +keys params = runEffectFn1 keysImpl params + +foreign import keysImpl :: EffectFn1 (URLSearchParams) (Array String) + +values :: URLSearchParams -> Effect (Array Foreign) +values params = runEffectFn1 valuesImpl params + +foreign import valuesImpl :: EffectFn1 (URLSearchParams) (Array Foreign) diff --git a/test/Main.purs b/test/Main.purs index 7a5d9ee..dd259bb 100644 --- a/test/Main.purs +++ b/test/Main.purs @@ -3,10 +3,12 @@ module Test.Main where import Prelude import Effect (Effect) -import Node.URL (format, parse, parseQueryString, toQueryString) +import Node.URL as URL import Test.Assert (assertEqual) main ∷ Effect Unit main = do - assertEqual { expected: "http://example.com/", actual: format $ parse "http://example.com/" } - assertEqual { expected: "foo=42", actual: toQueryString $ parseQueryString "foo=42" } + let urlStr = "http://example.com/" + url <- URL.new urlStr + urlStr' <- URL.format url + assertEqual { expected: urlStr, actual: urlStr' }