Search code examples
asp.netasp.net-mvcrestweb-services

Write our API end point in a standard way to cover all the business scenarios, in our ASP.NET MVC 5 web application


I have this Action method in ASP.NET MVC 5:

namespace LDAPMVCProject.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult UsersInfo(string username, string password)
        {
            DomainContext result = new DomainContext();

            try
            {
                // create LDAP connection object  
                DirectoryEntry myLdapConnection = createDirectoryEntry();
                string ADServerName = System.Web.Configuration.WebConfigurationManager.AppSettings["ADServerName"];
                string ADusername = System.Web.Configuration.WebConfigurationManager.AppSettings["ADUserName"];
                string ADpassword = System.Web.Configuration.WebConfigurationManager.AppSettings["ADPassword"];

                using (var context = new DirectoryEntry("LDAP://mydomain.com:389/DC=mydomain,DC=com", ADusername, ADpassword))
                using (var search = new DirectorySearcher(context))
                {
                    // validate username & password
                    using (var context2 = new PrincipalContext(ContextType.Domain, "mydomain.com", ADusername, ADpassword))
                    {
                        bool isvalid = context2.ValidateCredentials(username, password);
                        
                        if !(isvalid)
                            return **** // authentication error
                    }

                    // create search object which operates on LDAP connection object  
                    // and set search object to only find the user specified  

                    //    DirectorySearcher search = new DirectorySearcher(myLdapConnection);
                    //  search.PropertiesToLoad.Add("telephoneNumber");
                    search.Filter = "(&(objectClass=user)(sAMAccountName=test.test))";

                    // create results objects from search object  

                    // user exists, cycle through LDAP fields (cn, telephonenumber etc.)  
                    SearchResult r = search.FindOne();

                    ResultPropertyCollection fields = r.Properties;

                    foreach (String ldapField in fields.PropertyNames)
                    {
                        if (ldapField.ToLower() == "telephonenumber")
                        {
                            foreach (Object myCollection in fields[ldapField])
                            {
                                result.Telephone = myCollection.ToString();
                            }
                        }
                        else if (ldapField.ToLower() == "department")
                        {
                            foreach (Object myCollection in fields[ldapField])
                            {
                                result.Department = myCollection.ToString();
                            }
                        }
                       // }
                    }
                    
                    if (result.Telephone == null)
                        return ***** //Telephone is empty

                    if (result.Department)
                        return **** // department is empty

                    string output = JsonConvert.SerializeObject(result);

                    return Content(output, "application/json");//success
                }
            }
            catch (Exception e)
            {
                Console.WriteLine("Exception caught:\n\n" + e.ToString());
            }

            return View(result);
        }
    }
}

The action method acts as an API endpoint for our web application, where the API accepts username & password, and does the following:

  1. Validate the username/password against Active Directory

  2. If valid; check if the telephone number is empty >> if so return an error

  3. If valid; check if department is empty >> if so return an error

  4. If valid and info found; return the department & telephone for the user

Now I am a bit confused on how I need to return the JSON for the first 3 points? Should I always return http 200 with a status message (Status : "success" OR Status: "failed")? or if the username/password validation failed then i should return http 401 without having to return any JSON content?

Can anyone help me with this?

I need to write the action method in a standard way that can be consumed by 3rd party application.

Second question: what do I need to return in case the code raised an exception?

Thanks


Solution

  • This is an API error handling and logging design, and the following type of approach works well, to separate the concerns and keep your main logic clean:

    DESIGN ERROR RESPONSES

    These should be useful to clients, eg if they need to display an error or do something based on a specific cause. A 4xx error might have this payload, along with an HTTP status:

    {
      "code": "authentication_failed",
      "message": "Invalid credentials were provided"
    }
    

    A 500 error is often given a different payload based on what a UI will display in this case, and how you look the error up in logs:

    {
      "code": "authentication_error",
      "message": "A problem was encountered during a backend authentication operation",
      "area": "LDAP",
      "id": 12745,
      "utcTime": "2022-07-24T10:27:33.468Z"
    }
    

    DESIGN API LOGS

    In the first case the server logs might have fields such as these:

    {
      "id": "7af62b06-8c04-41b0-c428-de332436d52a",
      "utcTime": "2022-07-24T10:27:33.468Z",
      "apiName": "MyApi",
      "operationName": "getUserInfo",
      "hostName": "server101",
      "method": "POST",
      "path": "/userInfo",
      "errorData": {
        "statusCode": 401,
        "clientError": {
          "code": "authentication_failed",
          "message": "Invalid credentials were provided",
          "context": "The account is locked out"
        }
      }
    }
    

    In the second case the server logs might have fields such as these:

    {
      "id": "7af62b06-8c04-41b0-c428-de332436d52a",
      "utcTime": "2022-07-24T10:27:33.468Z",
      "apiName": "MyApi",
      "operationName": "getUserInfo",
      "hostName": "server101",
      "method": "POST",
      "path": "/userInfo",
      "errorData": {
        "statusCode": 500,
        "clientError": {
          "code": "authentication_error",
          "message": "A problem was encountered during a backend authentication operation",
          "area": "LDAP",
          "id": 12745,
          "utcTime": "2022-07-24T10:27:33.468Z"
        },
        "serviceError": {
          "details": "Host not found: error MS78245",
          "stack": [
            "Error: An unexpected exception occurred in the API",
            "at DirectorySearcher: 871 ... "
          ]
      }
    }
    

    CODE

    Perhaps aim to use code similar to this, to represent your desired error and logging behaviour. The ClientError and ServiceError classes enable the above responses and logs. When errors are thrown this should enable you to add useful contextual info:

    public class HomeController : Controller
    {
        public ActionResult UsersInfo(string username, string password)
        {
            DomainContext result = new DomainContext();
    
            try
            {
                DirectoryEntry myLdapConnection = createDirectoryEntry();
                string ADServerName = System.Web.Configuration.WebConfigurationManager.AppSettings["ADServerName"];
                string ADusername = System.Web.Configuration.WebConfigurationManager.AppSettings["ADUserName"];
                string ADpassword = System.Web.Configuration.WebConfigurationManager.AppSettings["ADPassword"];
    
                using (var context = new DirectoryEntry("LDAP://mydomain.com:389/DC=mydomain,DC=com", ADusername, ADpassword))
                using (var search = new DirectorySearcher(context))
                {
                    using (var context2 = new PrincipalContext(ContextType.Domain, "mydomain.com", ADusername, ADpassword))
                    {
                        bool isvalid = context2.ValidateCredentials(username, password);
                        if !(isvalid)
                            throw new ClientError(401, "authentication_failed", "Invalid credentials were provided", "optional context goes here");
                    }
    
                    DirectorySearcher search = new DirectorySearcher(myLdapConnection);
                    search.Filter = "(&(objectClass=user)(sAMAccountName=test.test))";
    
                    SearchResult r = search.FindOne();
                    ResultPropertyCollection fields = r.Properties;
                    foreach (String ldapField in fields.PropertyNames)
                    {
                        if (ldapField.ToLower() == "telephonenumber")
                        {
                            foreach (Object myCollection in fields[ldapField])
                            {
                                result.Telephone = myCollection.ToString();
                            }
                        }
                        else if (ldapField.ToLower() == "department")
                        {
                            foreach (Object myCollection in fields[ldapField])
                            {
                                result.Department = myCollection.ToString();
                            }
                        }
                    }
                    
                    if (result.Telephone == null)
                        throw new ClientError(400, "invalid_user_data", "User data is invalid", "Telephone is missing");
    
                    if (result.Department)
                        throw new ClientError(400, "invalid_user_data", "User data is invalid", "Department is missing");
    
                    string output = JsonConvert.SerializeObject(result);
                    return Content(output, "application/json");
                }
            }
            catch (Exception e)
            {
                throw new ServiceError("authentication_error", "A problem was encountered during a backend authentication operation", "LDAP", e);
            }
    
            return View(result);
        }
    }
    

    MIDDLEWARE

    The usual pattern is then to use small middleware classes to deal with processing exceptions, returning error responses and writing error logs:

    The type of logic written here will depend a little on your preferences, but might look similar to this:

    public class ErrorFilterAttribute : HandleErrorAttribute
    {
        public override void OnException(ExceptionContext filterContext)
        {
            var logEntry = new ErrorLogEntry();
            var jsonResponse = ""
            var statusCode = 500;
    
            if (filterContext.Exception is ClientError)
            {
                var clientError = filterContext.Exception as ClientError;
                logEntry.AddClientErrorDetails(clientError);
                statusCode = clientError.StatusCode;
                jsonResponse = clientError.toResponseFormat();
            }
            if (filterContext.Exception is ServiceError)
            {
                var serviceError = filterContext.Exception as ServiceError;
                logEntry.AddServiceErrorDetails(serviceError);
                statusCode = serviceError.StatusCode;
                jsonResponse = serviceError.toResponseFormat();
            }
            logEntry.Write();
    
            filterContext.Result = new JsonResult(jsonResponse);
            filterContext.HttpContext.Response.Clear();
            filterContext.HttpContext.Response.StatusCode = statusCode;
            filterContext.ExceptionHandled = true;
        }
    }