A Simple RESTful Service: Difference between revisions

From DataFlex Wiki
Jump to navigationJump to search
m Link
m Bold code headings
Line 5: Line 5:
'''Warning''': This sample code exposes your customer table to the world (or, if using one of the security options below, to anybody who has valid credentials) - they can abuse that as they choose. It is probably not an approach you would want to take in a real-world environment without considerable enhancement! (Details on the [[Talk:A_Simple_RESTful_Service|discussion page]].)
'''Warning''': This sample code exposes your customer table to the world (or, if using one of the security options below, to anybody who has valid credentials) - they can abuse that as they choose. It is probably not an approach you would want to take in a real-world environment without considerable enhancement! (Details on the [[Talk:A_Simple_RESTful_Service|discussion page]].)


CustomerService.wo:
'''CustomerService.wo''':
<source lang="vdf">
<source lang="vdf">
Use cWebHttpHandler.pkg
Use cWebHttpHandler.pkg

Revision as of 14:59, 7 August 2019

To create a service we will require a WebApp, and within that an object of the cWebHttpHandler class.

The code below shows how you might write a simple RESTful API for the sample Customer table (from the DataFlex Examples: WebOrder or WebOrderMobile, which will, by default, have been installed under "C:\DataFlex nn.n Examples" if you chose to install the Examples during the installation process - it also uses the WebAppUser table from those examples).

Warning: This sample code exposes your customer table to the world (or, if using one of the security options below, to anybody who has valid credentials) - they can abuse that as they choose. It is probably not an approach you would want to take in a real-world environment without considerable enhancement! (Details on the discussion page.)

CustomerService.wo:

Use cWebHttpHandler.pkg
Use cJsonObject.pkg
Use cCustomerDataDictionary.dd

// Used to validate users and their passwords when using BASIC authentication
Open WebAppUser

// Definitions of the various authentication types we will support
Enum_List
    Define C_authTypeNone
    Define C_authTypeBasic
    Define C_authTypeBearer
End_Enum_List

Object oCustomerService is a cWebHttpHandler
    Property Integer  peAuthType        C_authTypeNone
    Property String   psApiKey
    Property String   psCachedBaseURL
    Property String[] pasPathParts
    Property Boolean  pbAllowAccess     False
    Property String   psRealm           "REST Demo"  // The "realm" for use in BASIC Auth
    
    Set psPath      to "customerAPI"  // The URL *within* the WebApp's virtual directory
    Set psVerbs     to "GET,POST,PATCH,DELETE"
    Set peErrorType to httpErrorJson
    
    Object oCustomer_DD is a cCustomerDataDictionary
    End_Object
    
    //
    // Utility methods
    //
    
    // This function will return the Base URL, so that URLs to other resources
    // within the API can be constructed. When first constructed it is then
    // cached in the psCachedBaseURL property so that we don't have to do it
    // each time, which is expensive on server messages and hurts performance.
    Function BaseURL Returns String
        String  sHost sPort sProt sBase sURL sPath
        Boolean bSec
        Integer iPos
        
        Get psCachedBaseURL                             to sBase
        
        If (sBase = "") Begin
            Get ServerVariable "SERVER_NAME"            to sHost
            Get ServerVariable "SERVER_PORT"            to sPort
            Get ServerVariable "SERVER_PORT_SECURE"     to bSec
            Get ServerVariable "URL"                    to sURL
            
            Get psRequestPath                           to sPath
            
            Move (RightPos(sPath, sURL))                to iPos
            Move (Left(sURL, (iPos - 1)))               to sURL
            
            // Note: Case should not matter, but it seems that in Postman it
            // does, so stick to lowercase for the protocol:
            Move (If(bSec, "https", "http"))            to sProt
            Move (sProt + "://" + sHost + ":" + ;
                                  sPort + sURL)         to sBase
            Set psCachedBaseURL                         to sBase
        End
        
        Function_Return sBase
    End_Function
    
    // A simple function to find a customer row from the appropriate part
    // of the invoking URL using its DD
    Function FindCustomerFromRequest Returns Boolean
        String[] asParts
        Boolean  bFound
        
        Get pasPathParts to asParts
        
        If (SizeOfArray(asParts) = 3) Begin
            Send Clear of oCustomer_DD
            Move asParts[2] to Customer.Customer_Number
            Send Find of oCustomer_DD EQ Index.1
            Move (Found) to bFound
            
            If bFound ;  // Check that you actually got the right one (should always be the case)
                Move (Customer.Customer_Number = asParts[2]) to bFound
            
            If not bFound ;
                Send SetResponseStatus 404 "Not Found"

        End
        Else ;  // This shouldn't ever happen, but...
            Send SetResponseStatus 400 "Bad Request"
        
        Function_Return bFound
    End_Function
    
    // A simple function to update a customer row from the passed JSON through
    // its DD. However see Wil's note regarding this on the discussion page.
    Function UpdateCustomerFromJson Returns Boolean
        Handle  hoReq
        UChar[] ucaReq
        Boolean bOK
        Integer i iMax iField iType
        String  sField sVal
        
        Get RequestDataUChar 0 to ucaReq  // Read the entire body into the UChar[]
        
        If (SizeOfArray(ucaReq) < 10) Begin  // '{"x":"y"}' is the minimum that
                                             // makes sense: 9 chars
            Send SetResponseStatus 400 "Bad Request"
            Function_Return False
        End
        
        Get Create (RefClass(cJsonObject)) to hoReq
        Get ParseUtf8 of hoReq ucaReq to bOK
        
        If not bOK Begin
            Send SetResponseStatus 400 "Bad Request"
            Function_Return False
        End
        
        Get MemberCount of hoReq to iMax
        Decrement iMax
        Move False to Err
        
        For i from 0 to iMax
            Get MemberNameByIndex of hoReq i to sField
            Field_Map Customer.File_Number sField to iField
            
            If iField Begin
                Get_Attribute DF_FIELD_TYPE of Customer.File_Number iField to iType
                Get MemberValue of hoReq sField to sVal
                
                If (iType = DF_DATE) ;
                    Move (ConvertFromClient(typeDate, sVal)) to sVal
                If (iType = DF_DATETIME) ;
                    Move (ConvertFromClient(typeDateTime, sVal)) to sVal
                Set Field_Changed_Value of oCustomer_DD iField to sVal
            End

        Loop
        
        If (Err) ;
            Send SetResponseStatus 400 "Bad Request"
        
        Function_Return (not(Err))
    End_Function
    
    // This will provide the entry-point to the API, listing all the collections
    // within it
    Procedure ApiRoot
        Handle  hoResp hoColls hoColl
        UChar[] ucaResp
        
        Get Create (RefClass(cJsonObject)) to hoColls
        Send InitializeJsonType of hoColls jsonTypeArray

        // Do these six lines for each collection in the API:
        Get Create (RefClass(cJsonObject)) to hoColl
        Send InitializeJsonType of hoColl jsonTypeObject
        Send SetMemberValue of hoColl "name" jsonTypeString "customers"
        Send SetMemberValue of hoColl "href" jsonTypeString (BaseURL(Self) + "/customers")
        Send AddMember of hoColls hoColl
        Send Destroy of hoColl
        
        // Create the response object and add the collections array to it:
        Get Create (RefClass(cJsonObject)) to hoResp
        Send InitializeJsonType of hoResp jsonTypeObject
        Send SetMember of hoResp "collections" hoColls
        Send Destroy of hoColls
        
        // Return the serialized response object:
        Get StringifyUtf8 of hoResp to ucaResp
        Send Destroy of hoResp
        Send OutputUChar ucaResp
    End_Procedure
    
    //
    // The worker procedures to perform the GET (to the customer collection),
    // GET (to a specific customer instance), POST (to the customer collection)
    // to create a new row, PATCH (to update an existing instance) and DELETE
    // (to delete an instance)
    //
    
    Procedure CustomerList
        Handle  hoResp hoCusts hoCust
        UChar[] ucaResp
        String  sVal
        
        Get Create (RefClass(cJsonObject)) to hoCusts
        Send InitializeJsonType of hoCusts jsonTypeArray
        
        Send Find of oCustomer_DD FIRST_RECORD Index.1
        
        While (Found)
            Get Create (RefClass(cJsonObject)) to hoCust
            Send InitializeJsonType of hoCust jsonTypeObject
            
            Get Field_Current_Value of oCustomer_DD Field Customer.Customer_Number to sVal
            Send SetMemberValue of hoCust "Customer_Number" jsonTypeInteger sVal

            Get Field_Current_Value of oCustomer_DD Field Customer.Name to sVal
            Send SetMemberValue of hoCust "Name" jsonTypeString sVal
            // More detail in the list can be provided by adding lines like the
            // two above
            
            // To be RESTful, we should provide links to our resources. The following
            // pair of lines provides a link to each specific customer detail from the
            // list - you might move this up to make it the first item for each entry
            // in the list, rather than the last.
            Get Field_Current_Value of oCustomer_DD Field Customer.Customer_Number to sVal
            Send SetMemberValue of hoCust "href" jsonTypeString (BaseURL(Self) + "/customers/" + sVal)
            
            Send AddMember of hoCusts hoCust
            Send Destroy of hoCust
            
            Send Find of oCustomer_DD NEXT_RECORD Index.1
        Loop        
        
        Get Create (RefClass(cJsonObject)) to hoResp
        Send InitializeJsonType of hoResp jsonTypeObject
        Send SetMember of hoResp "customers" hoCusts
        Send Destroy of hoCusts
        
        Get StringifyUtf8 of hoResp to ucaResp
        Send Destroy of hoResp
        Send OutputUChar ucaResp
    End_Procedure
    
    Procedure CustomerDetail
        Handle   hoResp hoCust
        UChar[]  ucaResp
        String   sVal sField
        Boolean  bFound
        Integer  i iMax iType iJType iPrec
        
        If not (FindCustomerFromRequest(Self)) ;
            Procedure_Return
        
        Get Create (RefClass(cJsonObject)) to hoCust
        Send InitializeJsonType of hoCust jsonTypeObject
        
        Get_Attribute DF_FILE_NUMBER_FIELDS of Customer.File_Number to iMax
        
        For i from 1 to iMax
            Get_Attribute DF_FIELD_NAME of Customer.File_Number i to sField
            Get_Attribute DF_FIELD_TYPE of Customer.File_Number i to iType
            Get Field_Current_Value of oCustomer_DD i to sVal
            
            If (iType = DF_DATE) ;
                Move (ConvertToClient(typeDate, sVal)) to sVal
            If (iType = DF_DATETIME) ;
                Move (ConvertToClient(typeDateTime, sVal)) to sVal
            
            If ((iType = DF_ASCII) or ;
                (iType = DF_DATE) or ;
                (iType = DF_DATETIME) or ;
                (iType = DF_TEXT)) ;
                Move jsonTypeString to iJType

            If (iType = DF_BCD) Begin
                Get_Attribute DF_FIELD_PRECISION of Customer.File_Number i to iPrec
                If (iPrec = 0) ;
                    Move jsonTypeInteger to iJType
                Else ;
                    Move jsonTypeDouble to iJType
            End
            
            Send SetMemberValue of hoCust sField iJType sVal
        Loop
        
        Get Create (RefClass(cJsonObject)) to hoResp
        Send InitializeJsonType of hoResp jsonTypeObject
        
        Send SetMember of hoResp "customer" hoCust
        
        Get StringifyUtf8 of hoResp to ucaResp
        Send Destroy of hoResp
        Send OutputUChar ucaResp
    End_Procedure
    
    Procedure CreateCustomer
        Handle  hoResp
        UChar[] ucaResp
        Boolean bErr
        
        Send Clear of oCustomer_DD
        
        If not (UpdateCustomerFromJson(Self)) ;
            Procedure_Return
        
        Get Validate_Save of oCustomer_DD to bErr
        
        If not bErr Begin
            Move False to Err
            Send Request_Save of oCustomer_DD
            Move (Err) to bErr
        End
        
        Get Create (RefClass(cJsonObject)) to hoResp
        Send InitializeJsonType of hoResp jsonTypeObject
        
        Send SetMemberValue of hoResp "Operation" jsonTypeString "Create Customer"
        Send SetMemberValue of hoResp "Result" jsonTypeString (If(bErr, "failed", "succeesed"))
        
        If not bErr ;
            Send SetMemberValue of hoResp "New Customer_Number" jsonTypeInteger Customer.Customer_Number
        
        Get StringifyUtf8 of hoResp to ucaResp
        Send Destroy of hoResp
        Send OutputUChar ucaResp
    End_Procedure
    
    Procedure UpdateCustomer
        Handle  hoResp
        UChar[] ucaResp
        Boolean bErr
        
        If not (FindCustomerFromRequest(Self)) ;
            Procedure_Return
        
        If not (UpdateCustomerFromJson(Self)) ;
            Procedure_Return
        
        Get Validate_Save of oCustomer_DD to bErr
        
        If not bErr Begin
            Move False to Err
            Send Request_Save of oCustomer_DD
            Move (Err) to bErr
        End
        
        
        Get Create (RefClass(cJsonObject)) to hoResp
        Send InitializeJsonType of hoResp jsonTypeObject
        
        Send SetMemberValue of hoResp "Operation" jsonTypeString "Update Customer"
        Send SetMemberValue of hoResp "Result" jsonTypeString (If(bErr, "failed", "succeeded"))

        Get StringifyUtf8 of hoResp to ucaResp
        Send Destroy of hoResp
        Send OutputUChar ucaResp
    End_Procedure
    
    Procedure DeleteCustomer
        Handle  hoResp
        UChar[] ucaResp
        Boolean bErr
        
        If not (FindCustomerFromRequest(Self)) ;
            Procedure_Return
        
        Get Validate_Delete of oCustomer_DD to bErr
        
        If not bErr Begin
            Move False to Err
            Send Request_Delete of oCustomer_DD
            Move (Err) to bErr
        End
        
        Get Create (RefClass(cJsonObject)) to hoResp
        Send InitializeJsonType of hoResp jsonTypeObject
        
        Send SetMemberValue of hoResp "Operation" jsonTypeString "Delete Customer"
        Send SetMemberValue of hoResp "Result" jsonTypeString (If(bErr, "failed", "scuceeded"))

        Get StringifyUtf8 of hoResp to ucaResp
        Send Destroy of hoResp
        Send OutputUChar ucaResp
    End_Procedure
    
    // If we are using "Basic Authentication", this function will validate that
    // the username and password passed with the request are valid in the
    // WebAppUser table
    Function ValidateBasic Returns Boolean
        String   sCreds
        String[] asCreds
        Integer  iLen
        Address  pAddr
        Boolean  bOK bValid
        
        Move False to bValid
        
        Get HttpRequestHeader "Authorization" to sCreds
        
        // Check that we have the right kind of authorization
        If (Left(Uppercase(sCreds), 6) = "BASIC ") Begin
            Move (Right(sCreds, (Length(sCreds) - 6)))      to sCreds
            
            // base64 decode the credentials string:
            Move (Length(sCreds)) to iLen
            Move (Base64Decode(AddressOf(sCreds), &iLen))   to pAddr
            Move (Repeat(Character(0), iLen)) to sCreds
            Move (MemCopy(AddressOf(sCreds), pAddr, iLen))  to bOK
            Move (Free(pAddr)) to bOK
            
            // Split username and password:
            Move (StrSplitToArray(sCreds, ":"))             to asCreds
            
            // Only if there are exactly two parts:
            If (SizeOfArray(asCreds) = 2) Begin
                
                // Is it a vaild user?
                Clear WebAppUser
                Move asCreds[0] to WebAppUser.LoginName
                Find eq WebAppUser by Index.1
                
                If (Found) Begin
                    Move (asCreds[1] = WebAppUser.Password) to bValid
                End
                
            End
            
        End
        
        If not bValid Begin
            // Deny access, but tell invoker that we are using basic auth:
            Send AddHttpResponseHeader "WWW-Authenticate" ;
                                       ('Basic realm="' + psRealm(Self) + '"')
            Send SetResponseStatus 401 "Unauthorized"
        End
        
        Function_Return bValid
    End_Function
    
    // If we are using "Bearer Authentication" (in this simle case with an
    // API key, issued to users), this will validate it
    Function ValidateBearer Returns Boolean
        String  sKey sPassed
        Boolean bOK
        
        Move False to bOK
        
        Get psApiKey to sKey
        Get HttpRequestHeader "Authorization" to sPassed
        
        // Check that we have the right kind of authorization
        If (Uppercase(Left(sPassed, 6)) = "BEARER") Begin
            Move (Right(sPassed, (Length(sPassed) - 7))) to sPassed
            Move (sPassed = sKey) to bOK
        End
        
        If not bOK ;
            Send SetResponseStatus 401 "Unauthorized"
        
        Function_Return bOK
    End_Function
    
    // OnPreRequest is called on every request, prior to other processing.
    // We will use it to work out whether to allow access for a request.
    Procedure OnPreRequest String sVerb String sPath
        Integer iAuthType
        
        Set pbAllowAccess to False
        
        Get peAuthType to iAuthType
        
        If (iAuthType = C_authTypeBasic) ;
            Set pbAllowAccess to (ValidateBasic(Self))
        Else If (iAuthType = C_authTypeBearer) ;
            Set pbAllowAccess to (ValidateBearer(Self))
        Else ;
            Set pbAllowAccess to True
        
    End_Procedure
    
    // This is the main procedure that works out how to handle (or reject) requests
    Procedure OnHttpRequest String sVerb String sPath String sContentType String sAcceptType Integer iSize
        String[] asPathParts
        UChar[] ucaResp
        Integer iParts
        
        If not (pbAllowAccess(Self)) ;
            Procedure_Return  // The return ststus will already have been set
                              // elsewhere - just exit
        
        // All of our responses will be in JSON (if they work), so we can set
        // the response content type here
        Send AddHttpResponseHeader "Content-type" "application/json"
        // Need to do this to mark response as not cacheable
        Send AddHttpResponseHeader "Cache-Control" "no-cache"
        
        // We parse the URL into parts and store those for later use as well as
        // using them in the Case statement below
        Move (StrSplitToArray(sPath, "/")) to asPathParts
        Move (SizeOfArray(asPathParts)) to iParts
        Set pasPathParts to asPathParts
        
        Case Begin
            
            Case ((sVerb = "GET") and ;
                  (sPath = ""))
                Send ApiRoot
                Case Break
            
            Case ((sVerb = "GET") and ;
                  (iParts = 2) and ;
                  (asPathParts[1] = "customers"))
                Send CustomerList
                Case Break
            
            Case ((sVerb = "GET") and ;
                  (iParts = 3) and ;
                  (asPathParts[1] = "customers") and ;
                  (asPathParts[2] <> ""))
                Send CustomerDetail
                Case Break
            
            Case ((sVerb = "POST") and ;
                  (iParts = 2) and ;
                  (asPathParts[1] = "customers"))
                Send CreateCustomer
                Case Break
            
            Case ((sVerb = "PATCH") and ;
                  (iParts = 3) and ;
                  (asPathParts[1] = "customers") ;
                  and (asPathParts[2] <> ""))
                Send UpdateCustomer
                Case Break
            
            Case ((sVerb = "DELETE") and ;
                  (iParts = 3) and ;
                  (asPathParts[1] = "customers") and ;
                  (asPathParts[2] <> ""))
                Send DeleteCustomer
                Case Break
            
            Case Else
                Send SetResponseStatus 400 "Bad Request"
            
        Case End
        
    End_Procedure
    
End_Object

// This procedure (which is called immediately) allows us to set up the 
// authentication type (NONE, BASIC or BEARER) to use, and in the case
// of BEARER, also set the API key, dynamically from parameters passed
// to the application on the command line, rather than hard-coding the
// properties involved.
Procedure SetAuthDetails
    Handle hoCmdLine
    Integer iNumArgs iArg
    String[] asArgs

    Get phoCommandLine of ghoApplication to hoCmdLine
    Get CountOfArgs of hoCmdLine To iNumArgs

    For iArg from 1 to iNumArgs
        Move (StrSplitToArray(Argument(hoCmdLine, iArg), "=")) to asArgs
        
        If (SizeOfArray(asArgs) = 2) Begin
            Move (Uppercase(asArgs[0])) to asArgs[0]
            
            If (asArgs[0] = "AUTH") Begin
                Move (Uppercase(asArgs[1])) to asArgs[1]
                If (asArgs[1] = "BASIC") ;
                    Set peAuthType of oCustomerService to C_authTypeBasic
                Else If (asArgs[1] = "BEARER") ;
                    Set peAuthType of oCustomerService to C_authTypeBearer
                Else ;
                    Set peAuthType of oCustomerService to C_authTypeNone
            End
        
            If (asArgs[0] = "APIKEY") Begin
                Set psApiKey of oCustomerService to asArgs[1]
            End
            
        End
        
    Loop
    
End_Procedure

Send SetAuthDetails

You should be able to copy and paste this code to see it in operation, as follows:

Your workspace should contain the tables Customer (perhaps copied from the WebOrder sample) and WebAppUser.

If your workspace does not already have a WebApp, in the Studio use File --> New --> Project --> Basic Web Project to create one first. Then with the WebApp as the current project (selector top-left on the Studio tool-bar by default), use File --> New --> Web Object --> WebHttpHandler, calling the object oCustomerService; then select all (CTRL-A) and paste the code above over the existing contents.

If you then compile and run the WebApp (F5), and change the URL in the web browser (Firefox is best for this, as it displays the returned JSON in the nicest format), replacing "Index.html" with "customerAPI" (the psPath setting in the code) you should see the output produced by the "ApiRoot" procedure, which will (at this point) have a single collection: "customers". Clicking (in Firefox, at least) on the "href" value for that should then display a list of customers, each of which should in turn have an "href" element, clicking on which will take you to the details for that customer.

To do more than simply display (i.e. GET) data, you will have to use a tool such as Postman (click the "Get Started" button, then the "Download" button, selecting the version - 32 or 64 bit - as appropriate for your machine, saving and then running the downloaded file) or the RESTTester tool detailed in the Consuming RESTful Services in DataFlex article.

To create new customer rows you will need to use POST to the customers collection (e.g.: .../customerAPI/customers), passing the required JSON in the request body (in Postman go to the request "Body" tab and select "raw" just below that). At a minimum that should be something like:

{
    "Name": "My test customer",
    "EMail_Address": "customer@sample.org"
}

To modify (update) an existing customer you will need to use PATCH to that customer's instance (e.g.: .../customerAPI/customers/101), passing the JSON to update it with, e.g.:

{
   "Address": "My house in my street",
   "Comments": "Where I live"
}

To delete a customer (which in most cases will be prevented by the Data Dictionary if that customer has orders attached to it) you will need to use DELETE to that customer's instance (e.g.: .../customerAPI/customers/101).

Security

The above sample has two different security mechanisms built in, although by default neither of them are active.

To activate them you should pass parameters on the command line (as a nicer alternative to hard-coding them) - these will be picked up by that "SetAuthDetails" procedure at the end of the example. This can be done in the WebApp Administrator. In that, select the application in question and right-click it, choosing the "Web Application Properties" option. In the "Parameters" box on the "General" tab enter the required parameters. (You will need to restart the web application for your changes to take effect: File --> Restart Web Application.)

To set up the same thing for debug-running in the Studio, do Project --> Project Properties <WebApp.src> and in the "General" tab enter the parameters you require in the "Parameters" box.

(Note: parameters are separated from each other by spaces.)

To use an authorization type of "Basic Auth" (username and password) pass the parameter "Auth=Basic", while to use a Bearer token use "Auth=Bearer ApiKey=WhateverKeyYouWantToUse". If you use bearer tokens you will not be able to access the API in a browser any more, however all browsers understand Basic Auth and will prompt you for credentials, which they will then "remember" for you (to make them "forget" those credentials again, do Ctrl-Shift-Del in the browser and select "Active Logins" in Firefox, or "Other Site Data" in Chrome - I can't work out how to do it in IE).

Of course, either of those authorization mechanisms rely on requests being made over a secure connection (HTTPS), so for deployment they absolutely must only be used in an environment where that is enforced, otherwise the passwords or API keys being employed will be exposed to any bad guys listening in on the connection.

See also