Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use coercion to encode query-string values in match->path #716

Merged
merged 16 commits into from
Jan 31, 2025
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
## UNRELEASED

* Improve OpenAPI docs, plus don't emit `:description` in the wrong place [#702](https://github.com/metosin/reitit/pull/702)
* *POTENTIALLY BREAKING* The frontend functions (href, push/replace-state, set-query) now
encode query-string values using configured coercion when possible (only Malli supports encoding).
- You can use this to encode query parameter values before they are URL-encoded. This works for DateTimes, collections etc.
- In most cases this shouldn't break existing uses, but it is possible even without
a custom encoding function, the default Malli string-transformer could encode some values differently
then previously.

## 0.7.2 (2024-09-02)

Expand Down
7 changes: 6 additions & 1 deletion doc/frontend/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ history events
- Stateful wrapper for easy use of history integration
- Optional [controller extension](./controllers.md)

You likely won't use `reitit.frontend` directly in your apps and instead you
will use the API documented in the browser integration docs, which wraps these
lower level functions.

## Core functions

`reitit.frontend` provides some useful functions wrapping core functions:
Expand All @@ -23,7 +27,8 @@ enabled.

`match-by-name` and `match-by-name!` with optional `path-paramers` and
logging errors to `console.warn` instead of throwing errors to prevent
React breaking due to errors.
React breaking due to errors. These can also [encode query-parameters](./coercion.md)
using schema from match data.

## Next

Expand Down
5 changes: 4 additions & 1 deletion doc/frontend/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ There are also secondary functions following HTML5 History API:
`push-state` to navigate to new route adding entry to the history and
`replace-state` to change route without leaving previous entry in browser history.

See [coercion notes](./coercion.md) to see how frontend route parameters
can be decoded and encoded.

## Fragment router

Fragment is simple integration which stores the current route in URL fragment,
Expand Down Expand Up @@ -62,7 +65,7 @@ event handler for page change events.

## History manipulation

Reitit doesn't include functions to manipulate the history stack, i.e.
Reitit doesn't include functions to manipulate the history stack, i.e.,
go back or forwards, but calling History API functions directly should work:

```
Expand Down
59 changes: 59 additions & 0 deletions doc/frontend/coercion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Frontend coercion

The Reitit frontend leverages [coercion](../coercion/coercion.md) for path,
query, and fragment parameters. The coercion uses the input schema defined
in the match data under `:parameters`.

## Behavior of Coercion

1. **Route Matching**
When matching a route from a path, the resulting match will include the
coerced values (if coercion is enabled) under `:parameters`. If coercion is
disabled, the parsed string values are stored in the same location.
The original un-coerced values are always available under `:path-params`,
`:query-params`, and `:fragment` (a single string).

2. **Creating Links and Navigating**
When generating a URL (`href`) or navigating (`push-state`, `replace-state`, `navigate`)
to a route, coercion can be
used to encode query-parameter values into strings. This happens before
Reitit performs basic URL encoding on the values. This feature is
especially useful for handling the encoding of specific types, such as
keywords or dates, into strings.

3. **Updating current query parameters**
When using `set-query` to modify current query parameters, Reitit frontend
first tries to find a match for the current path so the match can be used to
first decode query parameters and then to encode them. If the current path
doesn't match the routing tree, `set-query` keeps all the query parameter
values as strings.

## Notes

- **Value Encoding Support**: Only Malli supports value encoding.
- **Limitations**: Path parameters and fragment values are not encoded using
the match schema.

## Example

```cljs
(def router (r/router ["/"
["" ::frontpage]
["bar"
{:name ::bar
:coercion rcm/coercion
:parameters {:query [:map
[:q {:optional true}
[:keyword
{:decode/string (fn [s] (keyword (subs s 2)))
:encode/string (fn [k] (str "__" (name k)))}]]]}}]]))

(rfe/href ::bar {} {:q :hello})
;; Result "/bar?q=__hello", the :q value is first encoded

(rfe/push-state ::bar {} {:q :world})
;; Result "/bar?q=__world"
;; The current match will contain both the original value and parsed & decoded parameters:
;; {:query-params {:q "__world"}
;; :parameters {:query {:q :world}}}
```
Deraen marked this conversation as resolved.
Show resolved Hide resolved
34 changes: 33 additions & 1 deletion modules/reitit-core/src/reitit/coercion.cljc
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
(ns reitit.coercion
(:require [#?(:clj reitit.walk :cljs clojure.walk) :as walk]
[reitit.core :as r]
[reitit.impl :as impl])
#?(:clj
(:import (java.io Writer))))
Expand All @@ -19,7 +20,8 @@
(-open-model [this model] "Returns a new model which allows extra keys in maps")
(-encode-error [this error] "Converts error in to a serializable format")
(-request-coercer [this type model] "Returns a `value format => value` request coercion function")
(-response-coercer [this model] "Returns a `value format => value` response coercion function"))
(-response-coercer [this model] "Returns a `value format => value` response coercion function")
(-query-string-coercer [this model] "Returns a `value => value` query string coercion function"))

#?(:clj
(defmethod print-method ::coercion [coercion ^Writer w]
Expand Down Expand Up @@ -219,3 +221,33 @@
[match]
(if-let [coercers (-> match :result :coerce)]
(coerce-request coercers match)))

(defn coerce-query-params
"Uses an input schema and coercion implementation from the given match to
encode query-parameters map.

If no match, no input schema or coercion implementation, just returns the
original parameters map."
[match query-params]
(when query-params
(let [coercion (-> match :data :coercion)
schema (when coercion
(-compile-model coercion (-> match :data :parameters :query) nil))
coercer (when (and schema coercion)
(-query-string-coercer coercion schema))]
(if coercer
(let [result (coercer query-params :default)]
(if (error? result)
(throw (ex-info (str "Query parameters coercion failed")
result))
result))
query-params))))

(defn match->path
"Create routing path from given match and optional query-parameters map.

Query-parameters are encoded using the input schema and coercion implementation."
([match]
(r/match->path match))
([match query-params]
(r/match->path match (coerce-query-params match query-params))))
4 changes: 3 additions & 1 deletion modules/reitit-core/src/reitit/core.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@
(:template match) (:required match) path-params)))))

(defn match->path
"Create routing path from given match and optional query-parameters map."
([match]
(match->path match nil))
([match query-params]
(some-> match :path (cond-> (seq query-params) (str "?" (impl/query-string query-params))))))
(some-> match :path (cond-> (seq query-params)
(str "?" (impl/query-string query-params))))))

;;
;; Different routers
Expand Down
8 changes: 5 additions & 3 deletions modules/reitit-frontend/src/reitit/frontend.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@
(defn
^{:see-also ["reitit.core/match->path"]}
match->path
"Create routing path from given match and optional query-string map and
optional fragment string."
"Create routing path from given match and optional query-parameters map and
optional fragment string.

Query-parameters are encoded using the input schema and coercion implementation."
([match]
(match->path match nil nil))
([match query-params]
(match->path match query-params nil))
([match query-params fragment]
(when-let [path (r/match->path match query-params)]
(when-let [path (coercion/match->path match query-params)]
(cond-> path
(and fragment (seq fragment)) (str "#" (impl/form-encode fragment))))))

Expand Down
35 changes: 21 additions & 14 deletions modules/reitit-frontend/src/reitit/frontend/easy.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@
The URL is formatted using Reitit frontend history handler, so using it with
anchor element href will correctly trigger route change event.

Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first."
By default currently collections in query parameters are encoded as field-value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think "By default, collections ..." would read a bit better

pairs separated by &, i.e. \"?a=1&a=2\". To encode them differently, you can
either use Malli coercion to encode values, or just turn the values to strings
before calling the function."
([name]
(rfh/href @history name nil nil nil))
([name path-params]
Expand All @@ -69,9 +70,10 @@

Will also trigger on-navigate callback on Reitit frontend History handler.

Note: currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
By default currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\". To encode them differently, you can
either use Malli coercion to encode values, or just turn the values to strings
before calling the function.

See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState"
Expand All @@ -93,9 +95,10 @@

Will also trigger on-navigate callback on Reitit frontend History handler.

Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
By default currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\". To encode them differently, you can
either use Malli coercion to encode values, or just turn the values to strings
before calling the function.

See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState"
Expand All @@ -122,9 +125,10 @@

Will also trigger on-navigate callback on Reitit frontend History handler.

Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
By default currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\". To encode them differently, you can
either use Malli coercion to encode values, or just turn the values to strings
before calling the function.

See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
Expand All @@ -142,8 +146,11 @@
New query params can be given as a map, or a function taking
the old params and returning the new modified params.

Note: The query parameter values aren't coereced, so the
update fn will see string values for all query params."
The current path is matched against the routing tree, and the match data
(schema, coercion) is used to encode the query parameters.
If the current path doesn't match any route, the query parameters
are parsed from the path without coercion and new values
are also stored without coercion encoding."
([new-query-or-update-fn]
(rfh/set-query @history new-query-or-update-fn))
([new-query-or-update-fn {:keys [replace] :as opts}]
Expand Down
43 changes: 28 additions & 15 deletions modules/reitit-frontend/src/reitit/frontend/history.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,10 @@
The URL is formatted using Reitit frontend history handler, so using it with
anchor element href will correctly trigger route change event.

Note: currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first."
By default currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\". To encode them differently, you can
either use Malli coercion to encode values, or just turn the values to strings
before calling the function."
([history name]
(href history name nil))
([history name path-params]
Expand All @@ -208,9 +209,10 @@

Will also trigger on-navigate callback on Reitit frontend History handler.

Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
By default currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\". To encode them differently, you can
either use Malli coercion to encode values, or just turn the values to strings
before calling the function.

See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState"
Expand All @@ -236,9 +238,10 @@

Will also trigger on-navigate callback on Reitit frontend History handler.

Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
By default currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\". To encode them differently, you can
either use Malli coercion to encode values, or just turn the values to strings
before calling the function.

See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState"
Expand All @@ -264,9 +267,10 @@

Will also trigger on-navigate callback on Reitit frontend History handler.

Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
By default currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\". To encode them differently, you can
either use Malli coercion to encode values, or just turn the values to strings
before calling the function.

See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
Expand All @@ -289,13 +293,22 @@
New query params can be given as a map, or a function taking
the old params and returning the new modified params.

Note: The query parameter values aren't coereced, so the
update fn will see string values for all query params."
The current path is matched against the routing tree, and the match data
(schema, coercion) is used to encode the query parameters.
If the current path doesn't match any route, the query parameters
are parsed from the path without coercion and new values
are also stored without coercion encoding."
([history new-query-or-update-fn]
(set-query history new-query-or-update-fn nil))
([history new-query-or-update-fn {:keys [replace] :as opts}]
(let [current-path (-get-path history)
new-path (rf/set-query-params current-path new-query-or-update-fn)]
match (rf/match-by-path (:router history) current-path)
new-path (if match
(let [query-params (if (fn? new-query-or-update-fn)
(new-query-or-update-fn (:query (:parameters match)))
new-query-or-update-fn)]
(rf/match->path match query-params (:fragment (:parameters match))))
(rf/set-query-params current-path new-query-or-update-fn))]
(if replace
(.replaceState js/window.history nil "" (-href history new-path))
(.pushState js/window.history nil "" (-href history new-path)))
Expand Down
18 changes: 17 additions & 1 deletion modules/reitit-malli/src/reitit/coercion/malli.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@
(assoc error :transformed transformed))))
value))))))))

(defn- -query-string-coercer
"Create coercer for query-parameters, always allows extra params and does
encoding using string-transformer."
[schema transfomers options]
(let [;; Always allow extra paramaters on query-parameters encoding
open-schema (mu/open-schema schema)
;; Do not remove extra keys
string-transformer (-transformer string-transformer-provider (assoc options :strip-extra-keys false))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello! We've been testing these changes and we have a problem when using custom transformers. We think the problem might be this line. This line is ignoring the transfomers (there is a typo too) parameter and hard-coding string-transfomer-provider instead of using the transformers defined in the Reitit router configuration. Maybe it should be something like this?:

(defn- -query-string-coercer
  "Create coercer for query-parameters, always allows extra params and does
  encoding using string-transformer."
  [schema transfomers options]
  (let [;; Always allow extra paramaters on query-parameters encoding
        open-schema (mu/open-schema schema)
        ;; Do not remove extra keys
        ;; BEFORE: (-transformer (get-in transfomers [:string :default]) (assoc options :strip-extra-keys false))
        string-transformer (get-in transfomers [:string :default])
        encoder (m/encoder open-schema (assoc options :strip-extra-keys true) string-transformer)]
    (fn [value format]
      (if encoder
        (encoder value)
        value)))) 

Is there any reason for hard-coding string-transformer-provider?

Copy link
Member Author

@Deraen Deraen Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason is that create fn processes transformers map and applies the options to TransformerProviders, then the transformer we see after that step is one with mt/strip-extra-keys-transformer combined with the string-transformer.

I'll see what I can do.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, you're right. The encoder doesn't take the :strip-extra-keys option.

Maybe one solution could be to have a specific query string transformer provider that doesn't include the :strip-extra-keys transformer and include it by default in the Reitit router configuration.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lucassousaf Check the latest commit, :string transformer will be used now. I did consider adding a new transfomers option for query-string encoding, but I think decoding and encoding should use the same transformer so using :string does seem good idea now.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! At first glance it seems to work as expected. If we find anything (I don't think so, looks good) we can open an issue. Thanks a lot for your work @Deraen !

encoder (m/encoder open-schema options string-transformer)]
(fn [value format]
(if encoder
(encoder value)
value))))

;;
;; public api
;;
Expand Down Expand Up @@ -176,6 +190,8 @@
(-request-coercer [_ type schema]
(-coercer schema type transformers :decode opts))
(-response-coercer [_ schema]
(-coercer schema :response transformers :encode opts))))))
(-coercer schema :response transformers :encode opts))
(-query-string-coercer [_ schema]
(-query-string-coercer schema transformers opts))))))

(def coercion (create default-options))
4 changes: 3 additions & 1 deletion modules/reitit-schema/src/reitit/coercion/schema.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@
value))))
(-response-coercer [this schema]
(if (coerce-response? schema)
(coercion/-request-coercer this :response schema)))))
(coercion/-request-coercer this :response schema)))
(-query-string-coercer [this schema]
nil)))

(def coercion (create default-options))
Loading
Loading