luckyframework / lucky

A full-featured Crystal web framework that catches bugs for you, runs incredibly fast, and helps you write code that lasts.
https://luckyframework.org
MIT License
2.6k stars 157 forks source link

Native subdomain support #1166

Closed watzon closed 3 years ago

watzon commented 4 years ago

Thought there was already an existing issue, but I couldn't find one. I guess we've just talked about it in the gitter chat and it was never brought up here.

The problem

Subdomains are pretty commonly used to separate pieces of a site. You may want to give each user a subdomain based on their username, add subdomains for different regions, etc.

With Rails they have the notion of constraints, which allow you to constrain a route using different parameters, including a subdomain. For a dynamic subdomain you just subclass Constraint and put your resolving logic in there.

Solution?

I have a couple potential ideas for a solution. The first of which doesn't require many changes to the way things are already done. Consider the following action:

class Users::Index < BrowserAction
  get "/users/:username" do
    # Check for an existing user
    html IndexPage, user: user
  end
end

A simple way to handle subdomains would be to just add an optional sub or subdomain parameter to the existing routing macros. So the if I wanted to change the above code to use a subdomain instead, maybe we could do something like this:

class Users::Index < BrowserAction
  get "/", sub: ":username" do
    # Check for an existing user
    html IndexPage, user: user
  end
end

or for a non-wildcard type domain:

class Store::Index < BrowserAction
  get "/", sub: "store" do
    # Render store.mysite.com
    html IndexPage
  end
end

Another potential solution would be to do the Rails'y thing and use the concept of constraints. This would take a bit more work, but could allow routes to be further constrained using validations. How this could work would take a bit of discussion, but it would also make routing in Lucky even more powerful.

jwoertink commented 4 years ago

I feel like this was discussed in an issue somewhere. I could have sworn I saw an interface that looked more like

class Users::Index < BrowserAction
  subdomain ":username"
  get "/" do
    # Check for an existing user
    html IndexPage, user: user
  end
end

🤔

If not, then we would probably want to go this route anyway just so you can include it in a higher level or a module... Now the question becomes, do we allow for only 1 subdomain? What if we want 2 or more?

class Users::Index < BrowserAction
  # Only access this route when you're on
  # https://someuser.staging.members.mysite.com
  subdomain ":username.staging.members"
  get "/" do
    # Check for an existing user
    html IndexPage, user: user
  end
end
jwoertink commented 4 years ago

OH! It was the route prefix I was thinking of.

watzon commented 4 years ago

I think allowing multiple subdomains would be nice. It would be kind of like route prefix, but for the host.

jwoertink commented 4 years ago

Ok, so to throw some thoughts out there on this, here's a few questions:

Just a rough sketch, but maybe this would cover it?

macro subdomains(*domain_parts)
  before :subdomain_constraint

  {% for domain in domain_parts %}
    # if domain is a variable, make a method by
    # that name that takes the value from the request.host
  {% end %}
  def subdomain_constraint
    domain = @context.request.host.as(String)

    # some other complex logic
  end
end

Are there any other edge cases you can think of?

watzon commented 4 years ago

In my opinion:

Does a user need access to know which subdomain hit this action? - yes Do we restrict this action to the specified subdomain like a constraint? - yes Do we render a 404 if you hit it otherwise? - yes Do we add this to just a before pipe on any action that includes it? - probably, leaning towards yes

Nothing else I can think of personally. Seems like you covered the bases.

wout commented 4 years ago

I've been looking at one of our Rails apps using a lot of subdomain constraints, and I think this includes everything. There might be edge cases but starting with this, you'll make 99% of the users happy.

jwoertink commented 4 years ago

The main thing we are going to start doing with all Lucky features is always make sure there's an escape hatch. So use this thing how we intended, but if it doesn't do what you want, here's out to break out of it. So as long as we follow that, then I think it should all be good.

watzon commented 4 years ago

Looking forward to seeing a PR! I'd do it myself, but I don't know near enough about the router internals or have the time :sweat_smile:

jwoertink commented 4 years ago

I'm working on this, but thinking about some stuff. In these examples, we're saying that parts of your app need a subdomain constraint, but really it may be more common for your entire app to be constrained. If that's the case, you don't want all of your forms and links to need to include the subdomain. You shouldn't have to think about it.

This means that it needs to be configured globally with an escape hatch for specific actions... Let's say your app is a browser web app located at app.whatever.com, but your app has a few small api endpoints located at api.whatever.com. If you do link to: Things::Index, that should know app.whatever.com/things. Then if you need to pull up the api, it should be Api::Things::Index.with(subdomain: "api")...

Lucky::RouteHelper.configure do |settings|
  settings.base_uri = "whatever.com"
  settings.base_subdomain = "app"
end
class Home::Index < BrowserAction
  #....
end
# should know about the "app" subdomain already
link "Home", to: Home::Index

# skips the "app" subdomain, and uses this subdomain
link "Admin", to: Admin::Index.with(subdomain: "admin")

# Also skips the "app" subdomain and uses the "admin" subdomain....
link "Admin", to: Admin::Index

class Admin::Index < BrowserAction
  subdomain "admin"
  #....
end

Here's where it gets tricky.... What if your subdomain is global, and dynamic? For example, shopify gives you a whatever.myshopify.com address. So we know the base_uri is myshopify.com, but the base_subdomain is dynamic... we don't know what that is. My apps kinda do the same thing in staging because we load all our sites off the same domain in staging, then use the subdomain as the site lookup. In this case, do we just say that you ignore setting the value in the RouteHelper, and only set in the BrowserAction?

# if the subdomain is cheese.myshopify.com

# This should know about the cheese subdomain
link "Home", to: Home::Index

Ok, so maybe it's stored in session then? But this also means that cookies should be tied to that subdomain. You wouldn't want to store them on *.myshopify.com otherwise logging in to one store would have you logged in to another. So in this case, you'd want all cookies to be tied to that specific subdomain. How does that look with the session stuff? Does this subdomain thing need to take a block and pass context?

class Home::Index < BrowserAction
  subdomain ":storename" do
    # whatever the cookie thing is....
    context.cookie.domain("#{storename}.myshopify.com")
  end
end
watzon commented 4 years ago

You just had to go and use your brain and make things all complicated like :joy:

edwardloveall commented 4 years ago

Lot of good thoughts here. I've got a few thoughts.

I've always considered routes to conceptually cover the path of the url. Everything after the /, like the /foo/bar in https://example.com/foo/bar. The domain (or subdomain) is everything before that /. Because of this, I've never been a fan of Rails' constraints because mix domains and paths. I love @jwoertink's suggestion to make it separate from the route definition.

I also don't love the concept of a "subdomain" over just "domain" or "host". DNS does not discriminate. example.com, foo.example.com and bar.foo.example.com are all domains. The parts are subdomains, but it's a bit odd if your site is del.icio.us; the concept of a subdomain over a domain starts to break down.

Separating that idea may make some of these concepts cleaner and easier to implement (and abstract) because we're treating and modeling them like browsers and the internet treats them.

I'm not sure how possible this is, but I'd love to see domains/hosts live at the action level. Then links and child actions and cookies can pick up on them.

Example multi-tenant site (like shopify) where I have a main site and variable subdomains for each customer:

class BrowserAction
  host "store.com"
end
class UserStoreAction
  host ":username.store.com"
end

Then inside of a UserStoreAction link "Home", to: Home::Index can pick up that Home::Index is a BrowserAction and redirect to the proper host, but link "My Products", to: Store::Show.with(store: current_user.store) will also know which host to go to.

I'm not sure how cookies would work. Perhaps they have to be moved to actions as well.

watzon commented 4 years ago

I actually like those thoughts, especially when you take into account that some multi-tenant sites actually give you the ability to specify your own domain, not just subdomain. It would be nice if you could have full control over the entire thing and not be constrained to a single domain with the possibility of multiple subdomains.

host seems like a perfect solution to me.

jwoertink commented 4 years ago

Yeah, my app is multitenant with multiple domains, and subdomains. Like we have t.com, e.com, c.com, and then we have t.staging.devsite.com, e.staging.devsite.com, and c.staging.devsite.com.

Then we have a before pipe that does

def current_site
  host = @context.request.host.as(String)
  name = case Lucky::Env
  when .development?
  when .staging?
  when .production?
  else
  end

  SiteQuery.new.name(name).first
end

Now, I'm totally on board with what @edwardloveall is saying (also, nice name drop on the domain), but in this case it wouldn't work for my app unless that host took a method name, or you could do....

class BrowserAction
  host ":the_whole_thing_is_dynamic"
end

Because we have o.com and o.net. Now, with that said, we already have Lucky::RouteHelper that you configure to set your base_uri anyway. So you can already do Lucky::RouteHelper.settings.base_uri to get that static value.

I think where I'm getting a little foggy in is the concept of you can't hit the route unless that specific subdomain matches. I think what I'll do is I'll hammer out my thoughts in to code, then push up a WIP and ping y'all for a review. We can all hash it out with actual code to make it easier to see possible pitfalls or whatever.

Thanks for the help!

watzon commented 4 years ago

Can't wait! Thanks for being so proactive, both of you!

stephendolan commented 3 years ago

@jwoertink Thoughts on this being a 1.0 item to include? Not currently on our roadmap doc, but one of those things that I think most folks will expect frameworks to have an option for.

jwoertink commented 3 years ago

Yeah, I actually started working on this a while ago, but so many other things came up I forgot to get back to it. We can put this on the 1.0 roadmap as a nice to have. It'll for sure make it in, just not sure if actually in 1.0, or just after.

matthewmcgarvey commented 3 years ago

I think it might be getting too far into the weeds to go all the way to validating the host. I was looking around at how other frameworks solve this issue. Many do provide support for the whole host, but I came across https://github.com/fnando/sinatra-subdomain and think it's a fairly effective way to provide basic subdomain support without mucking around with a bunch of other stuff.

It could look something like:

class Users::Index < BrowserAction
  include SubdomainLibrary
  subdomain :admin

  get "/users" do
    # limited to admin.myapp.com/users
  end
end

There could be different ways of calling subdomain

In the route block there would be a subdomain method you could call to get the value of the subdomain.

I'm thinking when multiple subdomains are passed "e.staging.devsite.com" that the subdomain is "e.staging".

I think this covers 99% of use cases without being overly complicated.

matthewmcgarvey commented 3 years ago

I forgot that I made this https://github.com/matthewmcgarvey/lucky_subdomain

watzon commented 3 years ago

@matthewmcgarvey this is awesome! Still would love to see something like this built into the framework, but this is great.

watzon commented 3 years ago

Matthew you beautiful son of a gun. Well done!