redwoodjs / redwood

The App Framework for Startups
https://redwoodjs.com
MIT License
17.33k stars 994 forks source link

RFC: Optional path parameters #2429

Open Tobbe opened 3 years ago

Tobbe commented 3 years ago

Optional Path Parameters

Existing functionality

This is what you do to have a path parameter named id

<Route path="/user/{id}" page={UserStringPage} name="userString" />

You can add a constraint and a "type cast" by doing this, and you'll get a number instead of a string when you use it

<Route path="/user/{id:Int}" page={UserIntPage} name="userInt" />

You can even add custom types

const userRouteParamTypes = {
  slug: {
    constraint: /\w+-\w+/,
    transform: (param) => param.split('-'),
  }
}

<Router paramTypes={userRouteParamTypes}>
  <Route path="/post/{name:slug}" page={PostPage} name={post} />
</Router>

Rationale

@simoncrypta mentioned to me he would like to have a parameter that optionally defines the language to use, so you can have all routes that start with e.g. /fr/ be in French, everything that start's with /de/ be in German etc.

Proposed functionality

I propose we add support for optional parameters by appending a question mark (?) to the end of the param name.

In usage it would look like this

const userRouteParamTypes = {
  lang: {
    constraint: /en|fr|sv|de/,
  }
}

<Router paramTypes={userRouteParamTypes}>
  <Set wrap={[i18nContext, BlogContext, BlogLayout]}>
    <Route path="/{language?:lang}" page={HomePage} name="home" />
    <Route path="/{language?:lang}/about" page={AboutPage} name="about" />
    <Route path="/{language?:lang}/contact" page={ContactPage} name="contact" />
  </Set>
</Router>

If you navigated to /de/about you'd get the About page in German, if you navigated to /about you'd get it in whatever default language was configured.

Challenges

The biggest challenge is probably detecting if an optional parameter was passed or not. Like if we just had this

<Route path="/{opt?}" page={HomePage} name="home" />
<Route path="/{opt?}/about" page={AboutPage} name="about" />

When someone goes to /about, is it HomePage with opt set to "about", or is it About page with opt undefined? It might also not handle 404s as nicely as it would otherwise. If someone goes to /aboot currently that would be a 404. Now it would be a valid navigation to the Home page, with opt set to "aboot"

cannikin commented 3 years ago

In case everyone was wondering what Rails does... ;)

Optional path parts are surrounded with parenthesis:

get 'users(/:username)' => :index, :as => :users

So that responds to both /users and /users/cannikin

Once one part is optional, everything after that has to be optional as well, you can't have an optional in the middle and something required after that.

You can also nest the optionals, so you can define multiple possible routes all within one string:

get 'posts(/:slug(/comments))' => :index, :as => :posts

Which responds to /posts, /posts/hello-world and /posts/hello-world/comments. Note that in that example, the second optional isn't even a variable, it's a fixed string that has to be present. So if you tried /posts/hello-world/foobar that would not match.

The Rails router also has a splat that says "take the rest of the parts of the URL and put them all in a variable named X", like:

get 'posts/*rest' => :index, :as => :posts

Which means that a URL like /posts/hello-world/comments/123 (or any URL that starts with /posts) goes to the PostsController, with a param named rest that contains the string hello-world/comments/123. You can then parse that string manually and decide what to do.

As for making sure that the variable is one of a selection, the Rails routes can accept a constraints parameter that the variable has to match against in order to consider the route a match:

`get '/comments/:id' => :show, :constraints => { :id => /\d+/ }`

The constraint can also be a function ("block" in Ruby lingo) so you can do more complex processing and return true/false.

@mojombo and I talked about all of this when we were first building the router, but it was way too much to worry about when it was just starting out, so we started with the basics. If we had this functionality though it would definitely allow for maximum flexibility when defining your routes. And adding this syntax doesn't complicate the simple cases at all, you just keep doing what you're doing!

Tobbe commented 3 years ago

Thanks for your input @cannikin. This validates the idea I think. We're not crazy for wanting optional parts of the routes 🙂

But so, how would the Rails router solve @simoncrypta's use case with the language as an optional parameter at the start of the URL?

Once one part is optional, everything after that has to be optional as well, you can't have an optional in the middle and something required after that.

So for Simon's case every route would have to consist of only optional paths? That'd be weird 😬

simoncrypta commented 3 years ago

Thanks for your input @cannikin. This validates the idea I think. We're not crazy for wanting optional parts of the routes 🙂

But so, how would the Rails router solve @simoncrypta's use case with the language as an optional parameter at the start of the URL?

Once one part is optional, everything after that has to be optional as well, you can't have an optional in the middle and something required after that.

So for Simon's case every route would have to consist of only optional paths? That'd be weird 😬

That question me how they do i18n routing in Rails. I checked on the web some example and I found this one interesting They don't use the same method that @cannikin show us, but they give us a good hint for the use case with the language :

"To make the :locale parameter optional, you can pass in a regular expression with the available routes as in the code below"


# config/routes.rb

Rails.application.routes.draw do
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html

  scope "(:locale)", locale: /en|ja/ do
    resources :users, only: [:new, :show]
    root 'welcome#index'

    #... more routes
  end
end
cannikin commented 3 years ago

There's another part to the Rails syntax I forgot to include! You can define defaults for variables if they're not present:

get 'photos(/:filter)', to: 'photos#show', defaults: { filter: 'all' }

That one has the default on the end though, and since we want an optional first part I think you'd need two routes to encompass all the possibilities:

get ':language/about', to: 'pages#about'
get 'about', to: 'pages#about', defaults: { language: 'jpg' }

But that means you'd need two routes for each and every possible other route, ugh.

To in this situation, Rails has a scope directive which can surround a group of routes and acts as a prefix to all of them:

scope "(:locale)", constraints: { locale: /#{I18n.available_locales.join("|")}/ }, defaults: {locale: "en"} do
  get 'about', to: 'pages#about'
end

Note that this one does double duty, checking that the locale is one of a list defined in I18n.available_locales and if not, defaulting to "en".

So that responds to:

I know we ideally want a completely flat routes file but then it makes it much more difficult to do stuff like @simoncrypta wants to do...

cannikin commented 3 years ago

@simoncrypta posted his response just SECONDS before I hit send on mine! Great minds think alike! 😄

Tobbe commented 3 years ago

There's another part to the Rails syntax I forgot to include! You can define defaults for variables if they're not present:

We could add that functionality too, to our user route param types

const userRouteParamTypes = {
  lang: {
    constraint: /en|fr|sv|de/,
    default: 'fr',
  }
}
dzcpy commented 2 years ago

Any updaets?

ubugnu commented 2 years ago

The simplest way would be to create two rules:

const Routes = () => {
  return (
    <Router>
        <Route path="/user/{id:Int}" page={UserPage} name="user" />
        <Route path="/user/{id:Int}/{prop:String}" page={UserPage} name="user" />
    </Router>
  )
}

Then in page:

interface Props {
  id: number
  opt?: string
}

const UserPage = ({ id, opt }: Props) => {
  return (
    <>
      <MetaTags title="User" description="User page" />
      {(opt && <p>User page with some optional parameter</p>) || (
        <p>User page</p>
      )}
    </>
  )
}
illepic commented 1 year ago

To expound on @ubugnu 's answer, you must place the shorter route last so that any URL generation later on in your app does not explode:

const Routes = () => {
  return (
    <Router>
        <Route path="/user/{id:Int}/{prop:String}" page={UserPage} name="user" />
        <Route path="/user/{id:Int}" page={UserPage} name="user" />
    </Router>
  )
}
anaclumos commented 1 year ago

Also leaving my approach here: https://community.redwoodjs.com/t/implementing-internationalization-with-redwood-js/5004/6?u=anaclumos