diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 7e1246ffbb..5cd190d1b0 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -51,6 +51,26 @@ public class ConfigurationTests private const int RETRY_COUNT = 5; private const int RETRY_WAIT_SECONDS = 1; + /// + /// A valid REST API request body with correct parameter types for all the fields. + /// + public const string REQUEST_BODY_WITH_CORRECT_PARAM_TYPES = @" + { + ""title"": ""New book"", + ""publisher_id"": 1234 + } + "; + + /// + /// An invalid REST API request body with incorrect parameter type for publisher_id field. + /// + public const string REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES = @" + { + ""title"": ""New book"", + ""publisher_id"": ""one"" + } + "; + public TestContext TestContext { get; set; } [TestInitialize] @@ -946,6 +966,65 @@ public async Task TestPathRewriteMiddlewareForGraphQL( } } + /// + /// Validates the error message that is returned for REST requests with incorrect parameter type + /// when the engine is running in Production mode. The error messages in Production mode is + /// very generic to not reveal information about the underlying database objects backing the entity. + /// This test runs against a MsSql database. However, generic error messages will be returned in Production + /// mode when run against PostgreSql and MySql databases. + /// + /// Type of REST request + /// Endpoint for the REST request + /// Right error message that should be shown to the end user + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(RestMethod.Get, "/api/Book/id/one", null, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type on a table in production mode")] + [DataRow(RestMethod.Get, "/api/books_view_all/id/one", null, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type on a view in production mode")] + [DataRow(RestMethod.Get, "/api/GetBook?id=one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request on a stored-procedure with incorrect parameter type in production mode")] + [DataRow(RestMethod.Get, "/api/GQLmappings/column1/one", null, "Invalid value provided for field: column1", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type with alias defined for primary key column on a table in production mode")] + [DataRow(RestMethod.Post, "/api/Book", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a POST request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(RestMethod.Put, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a PUT request with incorrect primary key parameter type on a table in production mode")] + [DataRow(RestMethod.Put, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a bad PUT request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(RestMethod.Patch, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a PATCH request with incorrect primary key parameter type on a table in production mode")] + [DataRow(RestMethod.Patch, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a PATCH request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(RestMethod.Delete, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a DELETE request with incorrect primary key parameter type on a table in production mode")] + public async Task TestGenericErrorMessageForRestApiInProductionMode( + RestMethod requestType, + string requestPath, + string requestBody, + string expectedErrorMessage) + { + const string CUSTOM_CONFIG = "custom-config.json"; + TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostModeType.Production, TestCategory.MSSQL); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(requestType); + HttpRequestMessage request; + if (requestType is RestMethod.Get || requestType is RestMethod.Delete) + { + request = new(httpMethod, requestPath); + } + else + { + request = new(httpMethod, requestPath) + { + Content = JsonContent.Create(requestBody) + }; + } + + HttpResponseMessage response = await client.SendAsync(request); + string body = await response.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + Assert.IsTrue(body.Contains(expectedErrorMessage)); + } + } + /// /// Tests that the when Rest or GraphQL is disabled Globally, /// any requests made will get a 404 response. diff --git a/src/Service.Tests/SqlTests/SqlTestHelper.cs b/src/Service.Tests/SqlTests/SqlTestHelper.cs index 6947cfe076..c78f7e2b1c 100644 --- a/src/Service.Tests/SqlTests/SqlTestHelper.cs +++ b/src/Service.Tests/SqlTests/SqlTestHelper.cs @@ -226,7 +226,7 @@ public static HttpMethod GetHttpMethodFromOperation(Config.Operation operationTy /// /// /// HttpMethod corresponding the RestMethod provided as input. - private static HttpMethod ConvertRestMethodToHttpMethod(RestMethod? restMethod) + public static HttpMethod ConvertRestMethodToHttpMethod(RestMethod? restMethod) { switch (restMethod) { diff --git a/src/Service.Tests/TestHelper.cs b/src/Service.Tests/TestHelper.cs index e668c841a6..c29f4ea81c 100644 --- a/src/Service.Tests/TestHelper.cs +++ b/src/Service.Tests/TestHelper.cs @@ -237,5 +237,30 @@ public static void AddMissingEntitiesToConfig(RuntimeConfig config, string entit }, ""entities"": {}" + "}"; + + /// + /// Utility method that reads the config file for a given database type and constructs a + /// new config file with changes just in the host mode section. + /// + /// Name of the new config file to be constructed + /// HostMode for the engine + /// Database type + public static void ConstructNewConfigWithSpecifiedHostMode(string configFileName, HostModeType hostModeType, string databaseType) + { + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(databaseType); + RuntimeConfig config = configProvider.GetRuntimeConfiguration(); + HostGlobalSettings customHostGlobalSettings = config.HostGlobalSettings with { Mode = hostModeType }; + JsonElement serializedCustomHostGlobalSettings = + JsonSerializer.SerializeToElement(customHostGlobalSettings, RuntimeConfig.SerializerOptions); + Dictionary customRuntimeSettings = new(config.RuntimeSettings); + customRuntimeSettings.Remove(GlobalSettingsType.Host); + customRuntimeSettings.Add(GlobalSettingsType.Host, serializedCustomHostGlobalSettings); + RuntimeConfig configWithCustomHostMode = + config with { RuntimeSettings = customRuntimeSettings }; + File.WriteAllText( + configFileName, + JsonSerializer.Serialize(configWithCustomHostMode, RuntimeConfig.SerializerOptions)); + + } } } diff --git a/src/Service/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs index 1f38b029b7..f98d30b5c8 100644 --- a/src/Service/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs @@ -330,30 +330,6 @@ protected List GenerateOutputColumns() return outputColumns; } - /// - /// Gets the value of the parameter cast as the system type - /// of the column this parameter is associated with - /// - /// columnName is not a valid column of table or param - /// does not have a valid value type - protected object GetParamAsColumnSystemType(string param, string columnName) - { - Type systemType = GetColumnSystemType(columnName); - try - { - return ParseParamAsSystemType(param, systemType); - } - catch (Exception e) when (e is FormatException || e is ArgumentNullException || e is OverflowException) - { - throw new DataApiBuilderException( - message: $"Parameter \"{param}\" cannot be resolved as column \"{columnName}\" " + - $"with type \"{systemType.Name}\".", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest, - innerException: e); - } - } - /// /// Tries to parse the string parameter to the given system type /// Useful for inferring parameter types for columns or procedure parameters @@ -506,5 +482,62 @@ public void ProcessOdataClause(FilterClause odataClause) { return filterClause.Expression.Accept(visitor); } + + /// + /// Gets the value of the parameter cast as the system type + /// + /// Field value as a string + /// Field name whose value is being converted to the specified system type. This is used only for constructing the error messages incase of conversion failures + /// System type to which the parameter value is parsed to + /// The parameter value parsed to the specified system type + /// Raised when the conversion of parameter value to the specified system type fails. The error message returned will be different in development + /// and production modes. In production mode, the error message returned will be generic so as to not reveal information about the database object backing the entity + protected object GetParamAsSystemType(string fieldValue, string fieldName, Type systemType) + { + try + { + return ParseParamAsSystemType(fieldValue, systemType); + } + catch (Exception e) when (e is FormatException || e is ArgumentNullException || e is OverflowException) + { + + string errorMessage; + SourceType sourceTypeOfDbObject = MetadataProvider.EntityToDatabaseObject[EntityName].SourceType; + if (MetadataProvider.IsDevelopmentMode()) + { + if (sourceTypeOfDbObject is SourceType.StoredProcedure) + { + errorMessage = $@"Parameter ""{fieldValue}"" cannot be resolved as stored procedure parameter ""{fieldName}"" " + + $@"with type ""{systemType.Name}""."; + } + else + { + errorMessage = $"Parameter \"{fieldValue}\" cannot be resolved as column \"{fieldName}\" " + + $"with type \"{systemType.Name}\"."; + } + } + else + { + string fieldNameToBeDisplayedInErrorMessage = fieldName; + if (sourceTypeOfDbObject is SourceType.Table || sourceTypeOfDbObject is SourceType.View) + { + if (MetadataProvider.TryGetExposedColumnName(EntityName, fieldName, out string? exposedName)) + { + fieldNameToBeDisplayedInErrorMessage = exposedName!; + } + } + + errorMessage = $"Invalid value provided for field: {fieldNameToBeDisplayedInErrorMessage}"; + } + + throw new DataApiBuilderException( + message: errorMessage, + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest, + innerException: e); + + } + } + } } diff --git a/src/Service/Resolvers/Sql Query Structures/SqlDeleteQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlDeleteQueryStructure.cs index be434bbcc7..53f974ae2b 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlDeleteQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlDeleteQueryStructure.cs @@ -53,7 +53,7 @@ public SqlDeleteStructure( Predicates.Add(new Predicate( new PredicateOperand(new Column(DatabaseObject.SchemaName, DatabaseObject.Name, backingColumn!)), PredicateOperation.Equal, - new PredicateOperand($"{MakeParamWithValue(GetParamAsColumnSystemType(param.Value.ToString()!, backingColumn!))}") + new PredicateOperand($"{MakeParamWithValue(GetParamAsSystemType(param.Value.ToString()!, backingColumn!, GetColumnSystemType(backingColumn!)))}") )); } } diff --git a/src/Service/Resolvers/Sql Query Structures/SqlExecuteQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlExecuteQueryStructure.cs index 856260cb57..f854d0fdb9 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlExecuteQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlExecuteQueryStructure.cs @@ -44,22 +44,18 @@ public SqlExecuteStructure( if (requestParams.TryGetValue(paramKey, out object? requestParamValue)) { // Parameterize, then add referencing parameter to ProcedureParameters dictionary - try + string? parametrizedName = null; + if (requestParamValue is not null) { - string parameterizedName = MakeParamWithValue(requestParamValue is null ? null : - GetParamAsProcedureParameterType(requestParamValue.ToString()!, paramKey)); - ProcedureParameters.Add(paramKey, $"{parameterizedName}"); + Type systemType = GetUnderlyingStoredProcedureDefinition().Parameters[paramKey].SystemType!; + parametrizedName = MakeParamWithValue(GetParamAsSystemType(requestParamValue.ToString()!, paramKey, systemType)); } - catch (ArgumentException ex) + else { - // In the case GetParamAsProcedureParameterType fails to parse as SystemType from database metadata - // Keep message being returned to the client more generalized to not expose schema info - throw new DataApiBuilderException( - message: $"Invalid value supplied for field: {paramKey}", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest, - innerException: ex); + parametrizedName = MakeParamWithValue(value: null); } + + ProcedureParameters.Add(paramKey, $"{parametrizedName}"); } else { @@ -80,30 +76,5 @@ public SqlExecuteStructure( } } } - - /// - /// Gets the value of the parameter cast as the system type - /// of the stored procedure parameter this parameter is associated with - /// - private object GetParamAsProcedureParameterType(string param, string procParamName) - { - Type systemType = GetUnderlyingStoredProcedureDefinition().Parameters[procParamName].SystemType!; - try - { - return ParseParamAsSystemType(param, systemType); - } - catch (Exception e) - { - if (e is FormatException || - e is ArgumentNullException || - e is OverflowException) - { - throw new ArgumentException($@"Parameter ""{param}"" cannot be resolved as stored procedure parameter ""{procParamName}"" " + - $@"with type ""{systemType.Name}"".", innerException: e); - } - - throw; - } - } } } diff --git a/src/Service/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs index e5e689097f..c187845ebf 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs @@ -1,12 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; -using System.Net; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config; -using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; using Azure.DataApiBuilder.Service.Models; using Azure.DataApiBuilder.Service.Services; @@ -90,28 +87,17 @@ private void PopulateColumnsAndParams(string columnName, object? value) InsertColumns.Add(columnName); string paramName; - try + if (value is not null) { - if (value != null) - { - paramName = MakeParamWithValue( - GetParamAsColumnSystemType(value.ToString()!, columnName)); - } - else - { - paramName = MakeParamWithValue(null); - } - - Values.Add($"{paramName}"); + paramName = MakeParamWithValue( + GetParamAsSystemType(value.ToString()!, columnName, GetColumnSystemType(columnName))); } - catch (ArgumentException ex) + else { - throw new DataApiBuilderException( - message: ex.Message, - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest, - innerException: ex); + paramName = MakeParamWithValue(value: null); } + + Values.Add($"{paramName}"); } /// diff --git a/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs index 0d8865af54..8a21f38f7e 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs @@ -482,23 +482,12 @@ public void AddPaginationPredicate(IEnumerable afterJsonValues return; } - try - { - foreach (PaginationColumn column in afterJsonValues) - { - column.TableAlias = SourceAlias; - column.ParamName = column.Value is not null ? - MakeParamWithValue(GetParamAsColumnSystemType(column.Value!.ToString()!, column.ColumnName)) : - MakeParamWithValue(null); - } - } - catch (ArgumentException ex) + foreach (PaginationColumn column in afterJsonValues) { - throw new DataApiBuilderException( - message: ex.Message, - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest, - innerException: ex); + column.TableAlias = SourceAlias; + column.ParamName = column.Value is not null ? + MakeParamWithValue(GetParamAsSystemType(column.Value!.ToString()!, column.ColumnName, GetColumnSystemType(column.ColumnName))) : + MakeParamWithValue(value: null); } PaginationMetadata.PaginationPredicate = new KeysetPaginationPredicate(afterJsonValues.ToList()); @@ -520,7 +509,7 @@ private void PopulateParamsAndPredicates(string field, string backingColumn, obj if (value != null) { parameterName = MakeParamWithValue( - GetParamAsColumnSystemType(value.ToString()!, backingColumn)); + GetParamAsSystemType(value.ToString()!, backingColumn, GetColumnSystemType(backingColumn))); Predicates.Add(new Predicate( new PredicateOperand(new Column(DatabaseObject.SchemaName, DatabaseObject.Name, backingColumn, SourceAlias)), op, diff --git a/src/Service/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs index f10ac8faff..e10aba4bde 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs @@ -182,7 +182,7 @@ private Predicate CreatePredicateForParam(KeyValuePair param) new PredicateOperand( new Column(tableSchema: DatabaseObject.SchemaName, tableName: DatabaseObject.Name, param.Key)), PredicateOperation.Equal, - new PredicateOperand($"{MakeParamWithValue(GetParamAsColumnSystemType(param.Value.ToString()!, param.Key))}")); + new PredicateOperand($"{MakeParamWithValue(GetParamAsSystemType(param.Value.ToString()!, param.Key, GetColumnSystemType(param.Key)))}")); } return predicate; diff --git a/src/Service/Resolvers/Sql Query Structures/SqlUpsertQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlUpsertQueryStructure.cs index f81b446097..500190cea6 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlUpsertQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlUpsertQueryStructure.cs @@ -121,9 +121,9 @@ private void PopulateColumns( MetadataProvider.TryGetBackingColumn(EntityName, param.Key, out string? backingColumn); // Create Parameter and map it to column for downstream logic to utilize. string paramIdentifier; - if (param.Value != null) + if (param.Value is not null) { - paramIdentifier = MakeParamWithValue(GetParamAsColumnSystemType(param.Value.ToString()!, backingColumn!)); + paramIdentifier = MakeParamWithValue(GetParamAsSystemType(param.Value.ToString()!, backingColumn!, GetColumnSystemType(backingColumn!))); } else { diff --git a/src/Service/Services/MetadataProviders/CosmosSqlMetadataProvider.cs b/src/Service/Services/MetadataProviders/CosmosSqlMetadataProvider.cs index fb6ee3a6eb..13b4be19aa 100644 --- a/src/Service/Services/MetadataProviders/CosmosSqlMetadataProvider.cs +++ b/src/Service/Services/MetadataProviders/CosmosSqlMetadataProvider.cs @@ -23,6 +23,7 @@ public class CosmosSqlMetadataProvider : ISqlMetadataProvider private readonly RuntimeConfig _runtimeConfig; private Dictionary _partitionKeyPaths = new(); private Dictionary _graphQLSingularTypeToEntityNameMap = new(); + private readonly RuntimeConfigProvider _runtimeConfigProvider; /// public Dictionary GraphQLStoredProcedureExposedNameToEntityNameMap { get; set; } = new(); @@ -35,6 +36,7 @@ public class CosmosSqlMetadataProvider : ISqlMetadataProvider public CosmosSqlMetadataProvider(RuntimeConfigProvider runtimeConfigProvider, IFileSystem fileSystem) { _fileSystem = fileSystem; + _runtimeConfigProvider = runtimeConfigProvider; _runtimeConfig = runtimeConfigProvider.GetRuntimeConfiguration(); _entities = _runtimeConfig.Entities; @@ -235,5 +237,10 @@ public string GetDefaultSchemaName() { return string.Empty; } + + public bool IsDevelopmentMode() + { + return _runtimeConfigProvider.IsDeveloperMode(); + } } } diff --git a/src/Service/Services/MetadataProviders/ISqlMetadataProvider.cs b/src/Service/Services/MetadataProviders/ISqlMetadataProvider.cs index b628101dd4..9b09c0af50 100644 --- a/src/Service/Services/MetadataProviders/ISqlMetadataProvider.cs +++ b/src/Service/Services/MetadataProviders/ISqlMetadataProvider.cs @@ -159,5 +159,11 @@ public DatabaseObject GetDatabaseObjectForGraphQLType(string graphqlType) /// Retrieves the default schema name for this metadata provider. /// public string GetDefaultSchemaName(); + + /// + /// Returns true when the engine is running in Development mode. When running in Production + /// mode, it returns false. + /// + public bool IsDevelopmentMode(); } } diff --git a/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs index e5edd2471e..d467b5362c 100644 --- a/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs @@ -1492,6 +1492,11 @@ public bool VerifyForeignKeyExistsInDB( /// public void SetPartitionKeyPath(string database, string container, string partitionKeyPath) => throw new NotImplementedException(); + + public bool IsDevelopmentMode() + { + return _runtimeConfigProvider.IsDeveloperMode(); + } } }