haskell-servant / servant

Servant is a Haskell DSL for describing, serving, querying, mocking, documenting web applications and more!
https://docs.servant.dev/
1.83k stars 413 forks source link

Using Links in the more complicated projects without circular dependencies. #1034

Closed reygoch closed 5 years ago

reygoch commented 6 years ago

I usually split my API into several sub APIs / modules which I think is a normal thing to do, so I end up with e.g. RootAPI, UsersAPI, BlogAPI etc... UsersAPI and BlogAPI are "children" of RootAPI and if I want to use links in any of those sub APIs I have to import RootAPI so that I'm able to generate a link which leads to e.g. all users and since RootAPI already imports UsersAPI I get circular dependency and I can't think of any good way to avoid that dependency.

Only solution so far is that I just use plain Text for my links, or keep my whole API definition and implementation in one massive module (which is a no go for me).

So far it seems like the links feature is pretty much useless for anything serious. Am I missing something here?

alpmestan commented 6 years ago

Why not "define" (derive) the link making functions right in the module where you define the API type (but not necessarily the implementation) ? Note that you can do this "simultaneously" with the newly added support for record-based APIs: https://haskell-servant.readthedocs.io/en/release-0.14/cookbook/generic/Generic.html.

reygoch commented 6 years ago

@alpmestan It doesn't help. I have my Model.hs where I've defined data types my API is returning, than I have API.hs where I've defined my API. I can use allFieldLinks to create a record with all the links in the API.hs but than what? I usually have some ToJSON or ToHTML instance in my Model.hs file and If I want to use safe links in my ToHTML instance for my template I can't because I'd have to import API.hs into the Model.hs and API.hs is already importing Model.hs and I get an import cycle.

The only solutions I can see is to either define both my model and api in the same file ( along with all the required ToHtml instances ) which will in turn become a very large and unmanageable file, or use orphan instances to solve the cycle problem. None of those solutions seem appropriate for a project that isn't a simple toy example.

I hope I'm missing something here because safe links would be a really nice feature to have but at the moment it doesn't seem usable at all outside of small contrived examples.

alpmestan commented 6 years ago

Oh, there's a rather simple solution to your problem here I think. It's the first trick we go to when we've got that kind of circular dependency problem: just separate the core types into a dedicated .Type module. In your case:

Would that work for you?

reygoch commented 6 years ago

@alpmestan if I define instances for types in the Model.Type.hs in Model.hs doesn't that cause orphaned instances since the instance is not made in the same file as data type or class?

alpmestan commented 6 years ago

@reygoch It does, but if you import Model everywhere (except in API, which has to be imported by Model), and don't export Model.Type outside of the package, they're not "all that orphan", so to speak. It's not like you're writing instances for a type that's from another package.

phadej commented 6 years ago

Is the loop like:

data Routes route = Routes
    { someRoute :: "foo" :> Get '[HTML, JSON] Thingie
    , otherRoute :: ...
    }

data Thingie = Thingie

instance ToHtml Thingie where
    toHtml = ul_ $ do
    li_ $ a [ routeHref_ someRoute ] "Thingie"
    li_ $ a [ routeHref_ otherRoute ] "Something else"

then there are no clean non-orphan approach. There is a loop.

One "trick" is to make

data Routes' a route = Routes
    { someRoute :: "foo" :> Get '[HTML, JSON] a
    , ...
    }

-- now there aren't loop:
data Thingie = Thingie
type Routes = Routes' Thingie

instance ToHTML Thingie where
    toHtml = ul_ $ do
    li_ $ a [ routeHref_ someRoute ] "Thingie"
    li_ $ a [ routeHref_ otherRoute ] "Something else" 

This becomes painful is there are many entities, not only single Thingie

reygoch commented 6 years ago

@phadej yes, I guess only option are orphan instances then.

phadej commented 6 years ago

Another way is to use newtype HtmlOf a = HtmlOf (Html ()) for pages, with htmlOf :: ToHtml a => a -> SomeHtml a or something like that.

reygoch commented 6 years ago

@phadej this might work, but I get a feeling a loop is hiding in this approach as well, and you loose ability to return multiple formats from a single endpoint like '[HTML,JSON]...

phadej commented 6 years ago

@reygoch you don't, lazyness to the rescue: BundleOf a = BundleOf Aeson.Value Aeson.Encoding (Html ()) ...