Skip to content

Commit 5e3874e

Browse files
Fix Location Header in POST Response header (#1626)
## Why make this change? - Closes #1613 - According to [HTTP POST Specs](https://www.rfc-editor.org/rfc/rfc9110#name-post), when a POST request results in the creation of a new item, then a Location header field should be returned in the response header. > If one or more resources has been created on the origin server as a result of successfully processing a POST request, the origin server SHOULD send a [201 (Created)](https://www.rfc-editor.org/rfc/rfc9110#status.201) response containing a [Location](https://www.rfc-editor.org/rfc/rfc9110#field.location) header field that provides an identifier for the primary resource created ([Section 10.2.2](https://www.rfc-editor.org/rfc/rfc9110#field.location)) and a representation that describes the status of the request while referring to the new resource(s). - The Location header returned in the response headers were incorrect > Tables/View: `https://localhost:5001/api/Book//id/5009` > Stored Procedures: `https://localhost:5001/api/CountBooks//CountBooks` - When `base-route` is configured in the config file, the Location returned did not account for it. So, a subsequent GET request performed using the Location is unsuccessful. ## What is this change? - `RestController`: Logic to construct the `Location` header is updated to accommodate the `base-route` field - `RestService`: Helper method to extract the configured base-route is added - `ConfigurationTests`: Integration tests to validate the Location field when a) base-route is configured b) base-route is absent for tables and stored procedures are added. ## How was this tested? - [x] Integration Tests - [x] Manual Tests ## Sample Request(s) - Table and base-route is not configured ![image](https://github.com/Azure/data-api-builder/assets/11196553/9956423e-29ea-47eb-8c89-a11e0a27186f) - Stored Procedure and base-route is not configured ![image](https://github.com/Azure/data-api-builder/assets/11196553/1628386c-62d1-4113-a66d-489202e244d6) - Table and base-route is configured. Configured base-route: `/data-api` ![image](https://github.com/Azure/data-api-builder/assets/11196553/a7ad4c42-3745-4286-85ae-2770e8161949) - Stored Procedure and base-route is configured. Configured base-route: `/data-api` ![image](https://github.com/Azure/data-api-builder/assets/11196553/28d05694-b298-4ce5-9644-66f4b9921d0e)
1 parent e917ce9 commit 5e3874e

5 files changed

Lines changed: 233 additions & 9 deletions

File tree

src/Core/Resolvers/SqlMutationEngine.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,13 +253,16 @@ await _queryExecutor.ExecuteQueryAsync(
253253
{
254254
using (JsonDocument jsonDocument = JsonDocument.Parse(resultArray.ToJsonString()))
255255
{
256-
return new CreatedResult(location: context.EntityName, OkMutationResponse(jsonDocument.RootElement.Clone()).Value);
256+
// The final location header for stored procedures should be of the form ../api/<SP-Entity-Name>
257+
// Location header is constructed using the base URL, base-route and the set location value.
258+
// Since, SP-Entity-Name is already available in the base URL, location is set as an empty string.
259+
return new CreatedResult(location: string.Empty, OkMutationResponse(jsonDocument.RootElement.Clone()).Value);
257260
}
258261
}
259262
else
260263
{ // If no result set returned, just return a 201 Created with empty array instead of array with single null value
261264
return new CreatedResult(
262-
location: context.EntityName,
265+
location: string.Empty,
263266
value: new
264267
{
265268
value = JsonDocument.Parse("[]").RootElement.Clone()

src/Core/Services/RestService.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ public string GetRouteAfterPathBase(string route)
372372
// The RuntimeConfigProvider enforces the expectation that the configured REST path starts with a
373373
// forward slash '/'.
374374
configuredRestPathBase = configuredRestPathBase.Substring(1);
375+
375376
if (!route.StartsWith(configuredRestPathBase))
376377
{
377378
throw new DataApiBuilderException(
@@ -404,6 +405,20 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured
404405
return false;
405406
}
406407

408+
/// <summary>
409+
/// Helper method to extract the configured base route
410+
/// </summary>
411+
public string GetBaseRouteFromConfig()
412+
{
413+
if (_runtimeConfigProvider.TryGetConfig(out RuntimeConfig? config)
414+
&& config.Runtime.BaseRoute is not null)
415+
{
416+
return config.Runtime.BaseRoute;
417+
}
418+
419+
return string.Empty;
420+
}
421+
407422
/// <summary>
408423
/// Tries to get the Entity name and primary key route from the provided string
409424
/// returns the entity name via a lookup using the string which includes

src/Service.Tests/Configuration/ConfigurationTests.cs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,207 @@ public async Task TestGlobalFlagToEnableRestAndGraphQLForHostedAndNonHostedEnvir
13891389
}
13901390
}
13911391

1392+
/// <summary>
1393+
/// Validates the Location header field returned for a POST request when a 201 response is returned. The idea behind returning
1394+
/// a Location header is to provide a URL against which a GET request can be performed to fetch the details of the new item.
1395+
/// Base Route is not configured in the config file used for this test. If base-route is configured, the Location header URL should contain the base-route.
1396+
/// This test performs a POST request, and in the event that it results in a 201 response, it performs a subsequent GET request
1397+
/// with the Location header to validate the correctness of the URL.
1398+
/// </summary>
1399+
/// <param name="entityType">Type of the entity</param>
1400+
/// <param name="requestPath">Request path for performing POST API requests on the entity</param>
1401+
[DataTestMethod]
1402+
[TestCategory(TestCategory.MSSQL)]
1403+
[DataRow(EntitySourceType.Table, "/api/Book", DisplayName = "Location Header validation - Table, Base Route not configured")]
1404+
[DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", DisplayName = "Location Header validation - Stored Procedures, Base Route not configured")]
1405+
public async Task ValidateLocationHeaderFieldForPostRequests(EntitySourceType entityType, string requestPath)
1406+
{
1407+
1408+
GraphQLRuntimeOptions graphqlOptions = new(Enabled: false);
1409+
RestRuntimeOptions restRuntimeOptions = new(Enabled: true);
1410+
1411+
DataSource dataSource = new(DatabaseType.MSSQL,
1412+
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);
1413+
1414+
RuntimeConfig configuration;
1415+
1416+
if (entityType is EntitySourceType.StoredProcedure)
1417+
{
1418+
Entity entity = new(Source: new("get_books", EntitySourceType.StoredProcedure, null, null),
1419+
Rest: new(new SupportedHttpVerb[] { SupportedHttpVerb.Get, SupportedHttpVerb.Post }),
1420+
GraphQL: null,
1421+
Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) },
1422+
Relationships: null,
1423+
Mappings: null
1424+
);
1425+
1426+
string entityName = "GetBooks";
1427+
configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName);
1428+
}
1429+
else
1430+
{
1431+
configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions);
1432+
}
1433+
1434+
const string CUSTOM_CONFIG = "custom-config.json";
1435+
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
1436+
string[] args = new[]
1437+
{
1438+
$"--ConfigFileName={CUSTOM_CONFIG}"
1439+
};
1440+
1441+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
1442+
using (HttpClient client = server.CreateClient())
1443+
{
1444+
HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post);
1445+
HttpRequestMessage request = new(httpMethod, requestPath);
1446+
if (entityType is not EntitySourceType.StoredProcedure)
1447+
{
1448+
string requestBody = @"{
1449+
""title"": ""Harry Potter and the Order of Phoenix"",
1450+
""publisher_id"": 1234
1451+
}";
1452+
1453+
JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone();
1454+
request = new(httpMethod, requestPath)
1455+
{
1456+
Content = JsonContent.Create(requestBodyElement)
1457+
};
1458+
}
1459+
1460+
HttpResponseMessage response = await client.SendAsync(request);
1461+
1462+
// Location header field is expected only when POST request results in the creation of a new item
1463+
Assert.AreEqual(HttpStatusCode.Created, response.StatusCode);
1464+
1465+
string locationHeader = response.Headers.Location.AbsoluteUri;
1466+
1467+
// GET request performed using the Location header should be successful.
1468+
HttpRequestMessage followUpRequest = new(HttpMethod.Get, response.Headers.Location);
1469+
HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest);
1470+
Assert.AreEqual(HttpStatusCode.OK, followUpResponse.StatusCode);
1471+
1472+
// Delete the new record created as part of this test
1473+
if (entityType is EntitySourceType.Table)
1474+
{
1475+
HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, locationHeader);
1476+
await client.SendAsync(cleanupRequest);
1477+
}
1478+
}
1479+
}
1480+
1481+
/// <summary>
1482+
/// Validates the Location header field returned for a POST request when it results in a 201 response. The idea behind returning
1483+
/// a Location header is to provide a URL against which a GET request can be performed to fetch the details of the new item.
1484+
/// Base Route is configured in the config file used for this test. So, it is expected that the Location header returned will contain the base-route.
1485+
/// This test performs a POST request, and checks if it results in a 201 response. If so, the test validates the correctness of the Location header in two steps.
1486+
/// Since base-route has significance only in the SWA-DAB integrated scenario and this test is executed against DAB running independently,
1487+
/// a subsequent GET request against the Location header will result in an error. So, the correctness of the base-route returned is validated with the help of
1488+
/// an expected location header value. The correctness of the PK part of the Location string is validated by performing a GET request after stripping off
1489+
/// the base-route from the Location URL.
1490+
/// </summary>
1491+
/// <param name="entityType">Type of the entity</param>
1492+
/// <param name="requestPath">Request path for performing POST API requests on the entity</param>
1493+
/// <param name="baseRoute">Configured base route</param>
1494+
/// <param name="expectedLocationHeader">Expected value for Location field in the response header. Since, the PK of the new record is not known beforehand,
1495+
/// the expectedLocationHeader excludes the PK. Because of this, the actual location header is validated by checking if it starts with the expectedLocationHeader.</param>
1496+
[DataTestMethod]
1497+
[TestCategory(TestCategory.MSSQL)]
1498+
[DataRow(EntitySourceType.Table, "/api/Book", "/data-api", "http://localhost/data-api/api/Book/id/", DisplayName = "Location Header validation - Table, Base Route configured")]
1499+
[DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", "/data-api", "http://localhost/data-api/api/GetBooks", DisplayName = "Location Header validation - Stored Procedure, Base Route configured")]
1500+
public async Task ValidateLocationHeaderWhenBaseRouteIsConfigured(
1501+
EntitySourceType entityType,
1502+
string requestPath,
1503+
string baseRoute,
1504+
string expectedLocationHeader)
1505+
{
1506+
GraphQLRuntimeOptions graphqlOptions = new(Enabled: false);
1507+
RestRuntimeOptions restRuntimeOptions = new(Enabled: true);
1508+
1509+
DataSource dataSource = new(DatabaseType.MSSQL,
1510+
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);
1511+
1512+
RuntimeConfig configuration;
1513+
1514+
if (entityType is EntitySourceType.StoredProcedure)
1515+
{
1516+
Entity entity = new(Source: new("get_books", EntitySourceType.StoredProcedure, null, null),
1517+
Rest: new(new SupportedHttpVerb[] { SupportedHttpVerb.Get, SupportedHttpVerb.Post }),
1518+
GraphQL: null,
1519+
Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) },
1520+
Relationships: null,
1521+
Mappings: null
1522+
);
1523+
1524+
string entityName = "GetBooks";
1525+
configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName);
1526+
}
1527+
else
1528+
{
1529+
configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions);
1530+
}
1531+
1532+
const string CUSTOM_CONFIG = "custom-config.json";
1533+
1534+
AuthenticationOptions AuthenticationOptions = new(Provider: EasyAuthType.StaticWebApps.ToString(), null);
1535+
HostOptions staticWebAppsHostOptions = new(null, AuthenticationOptions);
1536+
1537+
RuntimeOptions runtimeOptions = configuration.Runtime;
1538+
RuntimeOptions baseRouteEnabledRuntimeOptions = new(runtimeOptions.Rest, runtimeOptions.GraphQL, staticWebAppsHostOptions, "/data-api");
1539+
RuntimeConfig baseRouteEnabledConfig = configuration with { Runtime = baseRouteEnabledRuntimeOptions };
1540+
File.WriteAllText(CUSTOM_CONFIG, baseRouteEnabledConfig.ToJson());
1541+
1542+
string[] args = new[]
1543+
{
1544+
$"--ConfigFileName={CUSTOM_CONFIG}"
1545+
};
1546+
1547+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
1548+
using (HttpClient client = server.CreateClient())
1549+
{
1550+
HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post);
1551+
HttpRequestMessage request = new(httpMethod, requestPath);
1552+
if (entityType is not EntitySourceType.StoredProcedure)
1553+
{
1554+
string requestBody = @"{
1555+
""title"": ""Harry Potter and the Order of Phoenix"",
1556+
""publisher_id"": 1234
1557+
}";
1558+
1559+
JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone();
1560+
request = new(httpMethod, requestPath)
1561+
{
1562+
Content = JsonContent.Create(requestBodyElement)
1563+
};
1564+
}
1565+
1566+
HttpResponseMessage response = await client.SendAsync(request);
1567+
Assert.AreEqual(HttpStatusCode.Created, response.StatusCode);
1568+
1569+
string locationHeader = response.Headers.Location.AbsoluteUri;
1570+
Assert.IsTrue(locationHeader.StartsWith(expectedLocationHeader));
1571+
1572+
// The URL to perform the GET request is constructed by skipping the base-route.
1573+
// Base Route field is applicable only in SWA-DAB integrated scenario. When DAB engine is run independently, all the
1574+
// APIs are hosted on /api. But, the returned Location header in this test will contain the configured base-route. So, this needs to be
1575+
// removed before performing a subsequent GET request.
1576+
string path = response.Headers.Location.AbsolutePath;
1577+
string completeUrl = path.Substring(baseRoute.Length);
1578+
1579+
HttpRequestMessage followUpRequest = new(HttpMethod.Get, completeUrl);
1580+
HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest);
1581+
Assert.AreEqual(HttpStatusCode.OK, followUpResponse.StatusCode);
1582+
1583+
// Delete the new record created as part of this test
1584+
if (entityType is EntitySourceType.Table)
1585+
{
1586+
HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, completeUrl);
1587+
await client.SendAsync(cleanupRequest);
1588+
}
1589+
1590+
}
1591+
}
1592+
13921593
/// <summary>
13931594
/// Engine supports config with some views that do not have keyfields specified in the config for MsSQL.
13941595
/// This Test validates that support. It creates a custom config with a view and no keyfields specified.

src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ await SetupAndRunRestApiTest(
297297
operationType: EntityActionOperation.Execute,
298298
requestBody: requestBody,
299299
expectedStatusCode: HttpStatusCode.Created,
300-
expectedLocationHeader: _integrationProcedureInsertOneAndDisplay_EntityName,
300+
expectedLocationHeader: string.Empty,
301301
expectJson: expectJson
302302
);
303303
}

src/Service/Controllers/RestController.cs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -224,16 +224,21 @@ private async Task<IActionResult> HandleOperation(
224224

225225
if (result is CreatedResult)
226226
{
227-
// Location is made up of two parts, the first being constructed
228-
// from the HttpRequest found in the HttpContext. The other part
229-
// is the primary key route, which has already been saved in the
227+
// Location is made up of three parts, the first being constructed
228+
// from the Host property found in the HttpContext.Request. The second part being the
229+
// base route configured in the config file.
230+
// The third part is the primary key route, which has already been saved in the
230231
// Location of the created result. So we form the entire location
231-
// from appending the primary key route already stored in the
232+
// from appending the base route and the primary key route already stored in the
232233
// created result to the url constructed from the HttpRequest. We
233234
// then update the Location of the created result to this value.
234235
CreatedResult createdResult = (result as CreatedResult)!;
235-
string location = UriHelper.GetEncodedUrl(HttpContext.Request) + "/" + createdResult.Location;
236-
createdResult.Location = location;
236+
string locationURL = UriHelper.BuildAbsolute(
237+
scheme: HttpContext.Request.Scheme,
238+
host: HttpContext.Request.Host,
239+
pathBase: _restService.GetBaseRouteFromConfig(),
240+
path: HttpContext.Request.Path);
241+
createdResult.Location = locationURL.EndsWith('/') ? locationURL + createdResult.Location : locationURL + "/" + createdResult.Location;
237242
result = createdResult;
238243
}
239244

0 commit comments

Comments
 (0)