Magento 2 – How to Create Custom Web API Call

apimagento-2.0magento2restxml

I'm trying to write a custom web api call in Magento 2. The point of the API call is to listen for a POST request containing an XML document containing order status updates from my fulfillment system. Here's my code so far:

webapi.xml

<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../app/code/Magento/Webapi/etc/webapi.xsd">

<route url="/V1/vakri/update" method="POST">
    <service class="Ezprints\Valkyrie\Model\Api\ValkyrieStatusUpdateInterface" method="processVaklyrieStatusUpdate"/>
    <resources>
        <resource ref="anonymous"/>
    </resources>
</route>

ValkyrieStatusUpdateInterface.php

namespace Ezprints\Valkyrie\Model\Api;

/**
 * Defines the service contract for handling Valkyrie status updates
 * posted to it.  The updates are necessary to update the status of the order.
 * The status updates will reflect the various stages of production (i.e.     assets
 * collected, shipped, complete, etc).
 */

interface ValkyrieStatusUpdateInterface
{
    /**
     * Parse the notification XML and handle accordingly
     * 
     * @api
     * @param string $xml
     * @return string $xmlResponse the result of the action
     */
    public function processVaklyrieStatusUpdate($xml);
}

ValkyrieStatusUpdate.php

namespace Ezprints\Valkyrie\Model;

use Ezprints\Valkyrie\Model\Api\ValkyrieStatusUpdateInterface;
use Ezprints\Valkyrie\Helper\Data;

class ValkyrieStatusUpdate extends Data implements ValkyrieStatusUpdateInterface
{
    /**
     * Parse the notification XML and handle accordingly
     *
     * @api
     * @param string $xml
     * @return string $xmlResponse the result of the action
     */
    public function processVaklyrieStatusUpdate($xml)
    {           
        try{
            if(empty($xml)){
                throw new Exception("No post data");
            } else {
                $xmlResponse = $this->processValkyrieUpdate($xml);
            }           
        } catch(\Exception $e){
            $xmlResponse = '<?xml version="1.0" encoding="UTF-8"?><OrderEventNotificationReceived Result="Failed"/>';
        }

        return $xmlRespnse;
    }
}

Here's my test script:
valkyrieSim.php

$xml = '<OrderEventNotification Id="E201602261456522075"><Order Id="000000024" EZPReferenceNumber="0-01-7899-201602242228-0027-3808"><Accepted DateTime="2016-02-26T21:27:55" /></Order></OrderEventNotification>';

$urlConn = curl_init();
$url     = 'http://local-klein.ezpstore.com/index.php/rest/V1/vakri/update';
$header[]       = 'Content-Type: application/xml';  

curl_setopt($urlConn, CURLOPT_URL, $url);
curl_setopt($urlConn, CURLOPT_POST, true);
curl_setopt($urlConn, CURLOPT_HTTPHEADER, $header);
curl_setopt($urlConn, CURLOPT_POSTFIELDS, $xml);
curl_setopt($urlConn, CURLOPT_RETURNTRANSFER, true);
curl_setopt($urlConn, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($urlConn, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($urlConn, CURLOPT_COOKIE, "XDEBUG_SESSION=ECLIPSE_DBGP");

$response = curl_exec($urlConn);

From the XML above, the two data points that I care about are the Accepted node and the Order ID attribute. The accepted node can be one of several different nodes that more or less tell Magento what state to move the order to. So while my example has an 'Accepted' node, it could also be 'Shipment' once the order has shipped or 'Complete' once the order is fulfilled and needs to be invoiced. The order ID should be self explanatory on why I need it.

So the theory is that my fulfillment system (Valkyrie) will post the XML and then that XML will get passed to the processValkyrieStatusUpdate method. However, I'm not getting that far. When I run the test script I get the following error:

{"message":"%fieldName is a required field.","parameters":{"fieldName":"xml"}

So I dug in the code and in vendor/magento/framework/Webapi/Rest/Request/Deserializer/Xml.php, it's converting the xml string into an array, and then running reset on the resulting array so the root node gets tossed out.

Edit: After the XML is deserialized, I'm left with this array

{
"_value":{
    "Order":{
        "_value":{
            "Accepted":{
                "_value":null,
                "_attribute":{
                    "DateTime":"2016-02-27T00:02:24"
                }
            }
        },
        "_attribute":{
            "Id":"000000024",
            "EZPReferenceNumber":"0-01-7899-201602242228-0027-3808"
        }
    }
},
"_attribute":{
    "Id":"E201602271456531344"
}
}

\EndEdit

Then the code in vendor/magento/framework/Webapi/ServiceInputProcessor.php (line 90-ish), it's checking in the result array it just made to see if there's an element in there called 'xml' after the variable I have defined in the interface.

So I changed the name of my param in the interface class to $_value like so because I know from stepping through the code that that value is there.

/**
 * Parse the notification XML and handle accordingly
 * 
 * @api
 * @param string $_value
 * @return string $xmlResponse the result of the action
 */
public function processVaklyrieStatusUpdate($_value);

Now when I run my test script, I get the following error from vendor/magento/framework/Reflection/TypeProcessor.php in the processSimpleAndAnyType function:

{"message":"Invalid type for value: \"array\". Expected Type: \"string\".","trace":null}

One more thing I tried was I tried changing the param type for $_value to array and got this error

{"message":"Class array does not exist","code":-1,

As near as I can tell, Magento is trying to convert my XML string into some kind of object and that's failing. All I want to do though is just pass in a XML string and get a string so that I can process it myself. I based the code and everything loosely on this article http://alankent.me/2015/07/24/creating-a-new-rest-web-service-in-magento-2/. The big difference between that article's approach and mine is he is using JSON requests whereas I'm using XML requests.

But at this point I'm completely stumped. So far I haven't even reached my processVaklyrieStatusUpdate method so I can execute the custom code I wrote. I don't really have many options for the POST request itself since that would require Valkyrie itself to get changed and I just don't see that happening anytime soon. Any ideas how I get the XML to my function?

Edit: I don't have any way to change the XML that's sent to the API endpoint. That's pretty much in Valkyrie's domain and so I have to have this work with the XML as it is in my test script. Also, it has to be XML format as Valkyrie doesn't support JSON requests.

Best Answer

Magento2 supports two formats of the messages for REST APIs: json and xml. Let's take json as an example: the api call for the service you created will looks like this:

curl -X POST "http://magento.url/rest/V1/vakri/update" -H "Content-Type: application/json" -d '{"xml": "<OrderEventNotification Id="E201602261456522075"> .. "}'

As you see here, you should have field, "xml", which corresponds to input parameter name of the method.

For XML it might be a bit more tricky, I would assume something like curl -X POST "http://magento.url/rest/V1/vakri/update" -H "Content-Type: application/xml" -d '<xml>"<OrderEventNotification Id="E201602261456522075"> .. "</xml>'

In any case, there will be a problem if xml code contains characters which can break the request json/xml structure. One way to overcome that is to obfuscate xml string with some encoding, let's say base64, or escape all dangerous characters. In API service implementation you would need to do reverse operation.

Another, and much better way, is to actually create the Data Object, which represents the data from the XML file. Instead of sending raw XML string, you'll just use possibilities of Web API framework to serialize/deserialize data.

namespace Ezprints\Valkyrie\Model\Api\Data;

interface OrderEventNotificationInterface
{
    /**
     * @return string
     */
     public function getId();

    /**
     * @return string
     */
    public function getOrderId();

    /**
     * @return string
     */
     public function getEZPReferenceNumber();

    /**
     * @return string
     */
    public function getAcceptedDateTime();
}

namespace Ezprints\Valkyrie\Model\Api;

interface ValkyrieStatusUpdateInterface
{
    /**
     * Parse the notification XML and handle accordingly
     * 
     * @api
     * @param Data\OrderEventNotificationInterface $orderNotification
     * @return Data\OrderEventNotificationResponseInterface
     */
    public function    processVaklyrieStatusUpdate(Data\OrderEventNotificationInterface $orderNotification);
}

API call will looks like:

curl -X POST "http://magento.url/rest/V1/vakri/update" -H "Content-Type: application/xml" -d '<id>E201602261456522075</id><order_id> 000000024</order_id><e_z_p_reference_number>0-01-7899-201602242228-0027-3808</e_z_p_reference_number><accepted_date_time>2016-02-26T21:27:55<accepted_date_time>'