diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 953e9a4..af180e8 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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. @@ -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. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 47ead8f..f82afef 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/src/Felicity.IntegrationTests/Routing.fs b/src/Felicity.IntegrationTests/Routing.fs index b1cfa84..1c67cc1 100644 --- a/src/Felicity.IntegrationTests/Routing.fs +++ b/src/Felicity.IntegrationTests/Routing.fs @@ -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() + |> 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" @> + } + ] diff --git a/src/Felicity/Felicity.fsproj b/src/Felicity/Felicity.fsproj index d8f4273..13e03ec 100644 --- a/src/Felicity/Felicity.fsproj +++ b/src/Felicity/Felicity.fsproj @@ -13,7 +13,7 @@ MIT 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 felicity-logo-128x128.png - 0.17.7 + 0.17.8 https://github.com/cmeeren/Felicity/blob/master/RELEASE_NOTES.md diff --git a/src/Felicity/IServiceCollectionExtensions.fs b/src/Felicity/IServiceCollectionExtensions.fs index 4a488fe..9c9850f 100644 --- a/src/Felicity/IServiceCollectionExtensions.fs +++ b/src/Felicity/IServiceCollectionExtensions.fs @@ -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>) : JsonApiConfigBuilder<'ctx> = @@ -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> @@ -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