Skip to content

Commit

Permalink
Allow BaseUrl and RelativeJsonApiRoot to be used together
Browse files Browse the repository at this point in the history
Fixes #7
  • Loading branch information
cmeeren committed Nov 4, 2021
1 parent 97a069f commit 0437c50
Show file tree
Hide file tree
Showing 5 changed files with 121 additions and 45 deletions.
71 changes: 51 additions & 20 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,15 +201,63 @@ This implies that `'ctx` should be mutable. You set the appropriate field(s) on

Meta is only returned for success responses. If the map is empty, `meta` is omitted from the response.

#### Base URLs
#### Placing the JSON:API routes in a subroute

If you want your JSON:API endpoints available within a sub-route, e.g. `myapi.com/foo/bar/articles`, you can use `RelativeJsonApiRoot` (leading/trailing slashes don’t matter):

```f#
member _.ConfigureServices(services: IServiceCollection) : unit =
services
.AddGiraffe()
.AddJsonApi()
.GetCtxAsyncRes(Context.getCtx)
.RelativeJsonApiRoot("foo/bar")
.Add()
.AddOtherServices(..)
```

The subroute can also be configured using `BaseUrl` as described below.

#### Custom base URL

JSON:API responses contain resource/relationship links. By default, Felicity infers these links from the HTTP request. For example, if your API is available both on `https://example.com` and `https://something-else.com`, then `GET https://example.com/articles` will return resources with `example.com` in their links, and for `GET https://something-else.com/articles` the resources will have `something-else.com` in their links.

If you want, you can use the `.BaseUrl` method after `.AddJsonApi()` to specify the base URL that will be used for all links.
If you want, you can use the `.BaseUrl` method to specify the base URL that will be used for all links (trailing slashes don’t matter):

```f#
member _.ConfigureServices(services: IServiceCollection) : unit =
services
.AddGiraffe()
.AddJsonApi()
.GetCtxAsyncRes(Context.getCtx)
.BaseUrl("https://example.com/foo/bar")
.Add()
.AddOtherServices(..)
```

The specified URL will be used as the base URL for all links in the responses. This may be useful if your API is behind a reverse proxy.

If the specified base URL contains a path (`/foo/bar` in the example above), this will also have the same effect as calling `RelativeJsonApiRoot` with that path, i.e., making the JSON:API endpoints available at the specified path.

You can, however, override this by also calling `RelativeJsonApiRoot` with your desired path (which can be `/` or empty if you want the endpoints available at the base of the domain). This may be needed e.g. if your API is behind a reverse proxy and the path of the public URL does not match the path used internally by the API/proxy. For example:

```f#
member _.ConfigureServices(services: IServiceCollection) : unit =
services
.AddGiraffe()
.AddJsonApi()
.GetCtxAsyncRes(Context.getCtx)
.BaseUrl("https://example.com/foo/bar")
.RelativeJsonApiRoot("/")
.Add()
.AddOtherServices(..)
```

With the configuration above, the links in the response would be like `https://example.com/foo/bar/articles/1`, but the actual calls to your API (e.g. the reverse proxy) would have to call `/articles` (and not `/foo/bar/articles`).

#### Multiple context types

You may call `AddJsonApi` multiple times for different context types. This may be useful if you have some collections/resource types that are only accessible to privileged users, which you can model with a different context type.
You may call `AddJsonApi` and `UseJsonApiEndpoints` multiple times for different context types. This may be useful if you have some collections/resource types that are only accessible to privileged users, which you can model with a different context type.

Note that Felicity also supports transforming the context (e.g. for authorization) for individual operations; see the section TODO for details.

Expand All @@ -227,23 +275,6 @@ Felicity uses ASP.NET Core’s endpoint routing. The startup sample above shows

Note that while ASP.NET Core’s endpoint routing is case insensitive, Felicity will check for correct casing of all JSON:API routes and return an error if the casing in the request is incorrect.

#### Placing the JSON:API routes in a subroute

If you want your JSON:API endpoints available within a subroute, e.g. `myapi.com/foo/bar/articles`, simply use `RelativeJsonApiRoot` (leading/trailing slashes doesn’t matter):

```f#
member _.ConfigureServices(services: IServiceCollection) : unit =
services
.AddGiraffe()
.AddJsonApi()
.GetCtxAsyncRes(Context.getCtx)
.RelativeJsonApiRoot("foo/bar")
.Add()
.AddOtherServices(..)
```

Alternatively you may use `.BaseUrl` to explicitly specify the whole base URL as described earlier.

#### Combining with other non-JSON:API routes

You may trivially add other non-Felicity routes in `Configure`. For example, you can add a Giraffe HttpHandler using `UseGiraffe(...)`, Giraffe.EndpointRouting routes using `UseEndpoints(fun e -> e.MapGiraffeEndpoints ...)`, or any other routing method supported by ASP.NET Core. Simply add the routes to your pipeline as you normally do.
Expand Down
4 changes: 4 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Release notes
==============

### 0.17.8 (2021-11-04)

* It is now possible to use both `BaseUrl` and `RelativeJsonApiRoot` together in order to place the JSON:API endpoints at a different path than the one specified in the base URL. See the documentation for details.

### 0.17.7 (2021-11-02)

* Fixed startup error when using `BaseUrl` with a URL without path
Expand Down
45 changes: 45 additions & 0 deletions src/Felicity.IntegrationTests/Routing.fs
Original file line number Diff line number Diff line change
Expand Up @@ -376,4 +376,49 @@ let tests =
test <@ json2 |> getPath "data.links.self" = "http://example.com/foo/bar/as/1" @>
}


testJob "If both base URL and relative root path is specified, uses the specified base URL in links regardless of actual request URL and root path, and makes the endpoints available at the root path" {

for path in ["baz/qux"; "/"; ""] do
let server =
new TestServer(
WebHostBuilder()
.ConfigureServices(fun services ->
services
.AddGiraffe()
.AddRouting()
.AddJsonApi()
.GetCtx(fun _ -> Ctx)
.BaseUrl("http://example.com/foo/bar")
.RelativeJsonApiRoot(path)
.Add()
|> ignore)
.Configure(fun app ->
app
.UseRouting()
.UseJsonApiEndpoints<Ctx>()
|> ignore
)
)
let client = server.CreateClient ()

let urlBasePath = if path.Trim('/') = "" then "" else "/" + path.Trim('/')

let! response1 =
Request.createWithClient client Get (Uri($"http://example.com{urlBasePath}/as/1"))
|> Request.jsonApiHeaders
|> getResponse
response1 |> testSuccessStatusCode
let! json1 = response1 |> Response.readBodyAsString
test <@ json1 |> getPath "data.links.self" = "http://example.com/foo/bar/as/1" @>

let! response2 =
Request.createWithClient client Get (Uri($"http://something-else.com{urlBasePath}/as/1"))
|> Request.jsonApiHeaders
|> getResponse
response2 |> testSuccessStatusCode
let! json2 = response2 |> Response.readBodyAsString
test <@ json2 |> getPath "data.links.self" = "http://example.com/foo/bar/as/1" @>
}

]
2 changes: 1 addition & 1 deletion src/Felicity/Felicity.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>f# fsharp jsonapi json-api json:api api rest rest-api api-rest api-server api-client web-api asp-net-core aspnetcore giraffe framework</PackageTags>
<PackageIcon>felicity-logo-128x128.png</PackageIcon>
<Version>0.17.7</Version>
<Version>0.17.8</Version>
<PackageReleaseNotes>https://github.com/cmeeren/Felicity/blob/master/RELEASE_NOTES.md</PackageReleaseNotes>
</PropertyGroup>

Expand Down
44 changes: 20 additions & 24 deletions src/Felicity/IServiceCollectionExtensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -30,39 +30,30 @@ type JsonApiConfigBuilder<'ctx> = internal {
configureSerializerOptions = None
}

/// Explicitly sets the base URL to be used in JSON:API responses. This is
/// optional; if not supplied, this will be inferred from the actual request
/// URL. (See also RelativeJsonApiRoot which you need to use then if your
/// jsonApi handler is not at the root level.)
///
/// This may not be combined with RelativeJsonApiRoot.
/// Explicitly sets the base URL to be used in JSON:API responses. If not supplied, the
/// base URL will be inferred from the actual request URL. If the specified base URL
/// contains a path, this will also have the same effect as calling RelativeJsonApiRoot
/// with that path (unless RelativeJsonApiRoot is configured explicitly).
///
/// Trailing slashes don't matter.
member this.BaseUrl(url: Uri) : JsonApiConfigBuilder<'ctx> =
if this.relativeJsonApiRoot.IsSome then failwith "BaseUrl and RelativeJsonApiRoot can not be mixed."
{ this with baseUrl = Some (url.ToString().TrimEnd('/')) }

/// Explicitly sets the base URL to be used in JSON:API responses. This is
/// optional; if not supplied, this will be inferred from the actual request
/// URL. (See also RelativeJsonApiRoot which you need to use then if your
/// jsonApi handler is not at the root level.)
///
/// This may not be combined with RelativeJsonApiRoot.
/// Explicitly sets the base URL to be used in JSON:API responses. If not supplied, the
/// base URL will be inferred from the actual request URL. If the specified base URL
/// contains a path, this will also have the same effect as calling RelativeJsonApiRoot
/// with that path (unless RelativeJsonApiRoot is configured explicitly).
///
/// Trailing slashes don't matter.
member this.BaseUrl(url: string) : JsonApiConfigBuilder<'ctx> =
if this.relativeJsonApiRoot.IsSome then failwith "BaseUrl and RelativeJsonApiRoot can not be mixed."
{ this with baseUrl = Some (url.TrimEnd('/')) }

/// Sets the relative root path for the JSON:API routes. For example, supplying the
/// value '/foo/bar' means that clients must call 'GET /foo/bar/articles' to query the
/// /articles collection.
///
/// This may not be combined with BaseUrl.
///
/// Leading/trailing slashes don't matter.
member this.RelativeJsonApiRoot(path: string) : JsonApiConfigBuilder<'ctx> =
if this.baseUrl.IsSome then failwith "BaseUrl and RelativeJsonApiRoot can not be mixed."
{ this with relativeJsonApiRoot = Some (path.Trim('/')) }

member this.GetCtxJobRes(getCtx: HttpContext -> Job<Result<'ctx, Error list>>) : JsonApiConfigBuilder<'ctx> =
Expand Down Expand Up @@ -90,16 +81,22 @@ type JsonApiConfigBuilder<'ctx> = internal {
{ this with configureSerializerOptions = Some configure }

member this.Add() =

let getBaseUrl =
match this.baseUrl with
| None ->
match this.baseUrl, this.relativeJsonApiRoot with
| None, None ->
fun (ctx: HttpContext) ->
let url = Uri(ctx.GetRequestUrl())
let baseUrl = url.Scheme + Uri.SchemeDelimiter + url.Authority
match this.relativeJsonApiRoot with None -> baseUrl | Some r -> baseUrl + "/" + r
| Some url ->
url.Scheme + Uri.SchemeDelimiter + url.Authority
| None, Some path ->
fun (ctx: HttpContext) ->
let url = Uri(ctx.GetRequestUrl())
url.Scheme + Uri.SchemeDelimiter + url.Authority + "/" + path
| Some url, _ ->
fun _ -> url

let getCtx = this.getCtx |> Option.defaultWith (fun () -> failwith "Must specify a context getter")

let configureSerializerOptions = this.configureSerializerOptions |> Option.defaultValue ignore

let resourceModules = ResourceModule.all<'ctx>
Expand Down Expand Up @@ -189,8 +186,7 @@ type JsonApiConfigBuilder<'ctx> = internal {
let relativeRootWithLeadingSlash =
match this.relativeJsonApiRoot, this.baseUrl with
| None, None -> ""
| Some _, Some _ -> failwith "Framework bug: Both relative root and base URL specified"
| Some root, None -> "/" + root
| Some root, _ -> if root = "" then "" else "/" + root
| None, Some url ->
let relativeRoot = Uri(url).PathAndQuery.Trim('/')
if relativeRoot = "" then "" else "/" + relativeRoot
Expand Down

0 comments on commit 0437c50

Please sign in to comment.