Using the Calendar API in PHP

In the same spirit as my Python experiment, I decided to give PHP a try and see how far I could get with the Office 365 APIs. The same disclaimers apply: I am a total PHP neophyte. I'm certain that my code is probably non-optimal, but hey, it's a learning experience!

The idea

I had this idea for a simple app that could make use of the Calendar API. The app is a list of upcoming shows for a local community theater's Shakespearean festival. Visitors to their site can connect their Office 365 account and then add events directly to their calendar for the shows that they are attending.

The setup

There are a lot of options for PHP development, on multiple platforms. I expect this sample to run on any of them (please let me know if it doesn't!). In my case, I decided to install IIS 8 on my laptop and install PHP via the Web Platform Installer. If you're developing on a Windows machine, this is super easy. In just a few short minutes I was up and running. I didn't have to install anything else, the installer included all of the libraries I needed.

The execution

I started by creating a home page (home.php) that would show a table of upcoming shows. To make it a little dynamic, I created a class to generate the list based on the current date, and to randomize the location and whether or not a voucher was required (more on this later). To keep things constant throughout a session, I save that list in the $_SESSION global so I can reuse it. I ended up with a page that looks like this:

Now to make the "Connect My Calendar" button do something.

OAuth

I decided to create a static class to implement all of the Office 365-related functionality. I created Office365Service.php and created the Office365Service class. The first thing we need it to do is "connect" the user's calendar. Under the covers what that really means is having the user logon and provide consent to the app, then retrieving an access token. If you're familiar with OAuth2, then this is pretty straightforward. Essentially, we need to implement the authorization code grant flow against Azure AD.

To start that process, the "Connect My Calendar" button will send the user right to the authorization code request link. I added the getLoginUrl function to build this link based on my client ID:

  private static $authority = "https://login.windows.net";
 private static $authorizeUrl = '/common/oauth2/authorize?client_id=%1$s&redirect_uri=%2$s&response_type=code';
 
 // Builds a login URL based on the client ID and redirect URI
 public static function getLoginUrl($redirectUri) {
   $loginUrl = self::$authority.sprintf(self::$authorizeUrl, ClientReg::$clientId,
     urlencode($redirectUri));
   error_log("Generated login URL: ".$loginUrl);
   return $loginUrl;
 }
 

I created authorize.php to serve as the redirect page, which is where Azure sends the authorization code response. All this file needs to do is extract the code parameter from the request, and use that to issue a token request.

  $session_state = $_GET['session_state'];
 
 ...
 
 // Use the code supplied by Azure to request an access token.
 $tokens = Office365Service::getTokenFromAuthCode($code, $redirectUri);
 

So that's the next function I added to Office365Service:

  // Sends a request to the token endpoint to exchange an auth code
 // for an access token.
 public static function getTokenFromAuthCode($authCode, $redirectUri) {
   // Build the form data to post to the OAuth2 token endpoint
   $token_request_data = array(
     "grant_type" => "authorization_code",
     "code" => $authCode,
     "redirect_uri" => $redirectUri,
     "resource" => "https://outlook.office365.com/",
     "client_id" => ClientReg::$clientId,
     "client_secret" => ClientReg::$clientSecret
   );
 
   // Calling http_build_query is important to get the data
   // formatted as Azure expects.
   $token_request_body = http_build_query($token_request_data);
 
   $curl = curl_init(self::$authority.self::$tokenUrl);
   curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
   curl_setopt($curl, CURLOPT_POST, true);
   curl_setopt($curl, CURLOPT_POSTFIELDS, $token_request_body);
 
   if (self::$enableFiddler) {
     // ENABLE FIDDLER TRACE
     curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
     // SET PROXY TO FIDDLER PROXY
     curl_setopt($curl, CURLOPT_PROXY, "127.0.0.1:8888");
   }
 
   $response = curl_exec($curl);
 
   $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
   if (self::isFailure($httpCode)) {
     return array('errorNumber' => $httpCode,
                  'error' => 'Token request returned HTTP error '.$httpCode);
   }
 
   // Check error
   $curl_errno = curl_errno($curl);
   $curl_err = curl_error($curl);
   if ($curl_errno) {
     $msg = $curl_errno.": ".$curl_err;
     return array('errorNumber' => $curl_errno,
                  'error' => $msg);
   }
 
   curl_close($curl);
 
   // The response is a JSON payload, so decode it into
   // an array.
   $json_vals = json_decode($response, true); 
   return $json_vals;
 }
 

As you can see, I used curl for issuing requests. I found it very well suited for the job. I could easily build the request body as a standard array, encode it with http_build_query, and send it. Handling the response was easy too, with the built-in JSON function json_decode. That puts the response into an easy to manage array.

Now, back in authorize.php, I can save the tokens into the $_SESSION global and redirect back to the home page:

  // Save the access token and refresh token to the session.
 $_SESSION['accessToken'] = $tokens['access_token'];
 $_SESSION['refreshToken'] = $tokens['refresh_token'];
 // Parse the id token returned in the response to get the user name.
 $_SESSION['userName'] = Office365Service::getUserName($tokens['id_token']);
 
 // Redirect back to the homepage.
 $homePage = "http".(($_SERVER["HTTPS"] == "on") ? "s://" : "://")
                   .$_SERVER["HTTP_HOST"]."/php-calendar/home.php"; 
 header("Location: ".$homePage);
 

Notice that I also get the user's name from the ID token. This is just so I can display the logged on user's name in my app. Check the getUserName function in Office365Service.php if you're interested to see how that's done.

Calendar API

Now that the user can login, my homepage looks a little different:

Notice that the buttons now say "Add to Calendar", and the user's name appears in the top right along with a "logout" link. The logout link is very simple, it just goes to:

  https://login.windows.net/common/oauth2/logout?post_logout_redirect_uri=<URL to post-logout page>
 

The value you set in the post_logout_redirect_uri is where Azure will send the browser after logging the user out. In my case, I created logout.php, which removes the data I stored in the $_SESSION global and then redirects to the home page.

CalendarView

Now on to what we came to see, the Calendar API. Clicking on the "Add to Calendar" takes us to the addToCalendar.php page:

So the first use of the Calendar API is the table of events on the right-hand side. To get this data, I created the getEventsForData function in Office365Service.php:

  // Uses the Calendar API's CalendarView to get all events
 // on a specific day. CalendarView handles expansion of recurring items.
 public static function getEventsForDate($access_token, $date) {
   // Set the start of our view window to midnight of the specified day.
   $windowStart = $date->setTime(0,0,0);
   $windowStartUrl = self::encodeDateTime($windowStart);
 
   // Add one day to the window start time to get the window end.
   $windowEnd = $windowStart->add(new DateInterval("P1D"));
   $windowEndUrl = self::encodeDateTime($windowEnd);
 
   // Build the API request URL
   $calendarViewUrl = self::$outlookApiUrl."/Me/CalendarView?"
                     ."startDateTime=".$windowStartUrl
                     ."&endDateTime=".$windowEndUrl
                     ."&\$select=Subject,Start,End" // Limit the data returned
                     ."&\$orderby=Start"; // Sort the results by the start time.
 
   return self::makeApiCall($access_token, "GET", $calendarViewUrl);
 }
 

This function uses the CalendarView API to get the list of events on a specific day. The advantage of using CalendarView is that when responding to a CalendarView request, the server handles expanding recurring meetings to figure out if a recurring meeting has an instance that falls in the specified time window. The instance will be included in the results like a normal appointment!

You may have noticed that the function ends by calling another function, makeApiCall. Even though it's a detour from the Calendar API, let's take a quick look at that function, because it shows some things that apply to all of the Office 365 REST APIs.

  // Make an API call.
 public static function makeApiCall($access_token, $method, $url, $payload = NULL) {
   // Generate the list of headers to always send.
   $headers = array(
     "User-Agent: php-calendar/1.0", // Sending a User-Agent header is a best practice.
     "Authorization: Bearer ".$access_token, // Always need our auth token!
     "Accept: application/json", // Always accept JSON response.
     "client-request-id: ".self::makeGuid(), // Stamp each request with a new GUID.
     "return-client-request-id: true"// The server will send request-id in response.
   );
 
   $curl = curl_init($url);
 
   if (self::$enableFiddler) {
     // ENABLE FIDDLER TRACE
     curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
     // SET PROXY TO FIDDLER PROXY
     curl_setopt($curl, CURLOPT_PROXY, "127.0.0.1:8888");
   }
 
   switch(strtoupper($method)) {
     case "GET":
       // Nothing to do, GET is the default and needs no
       // extra headers.
       break;
     case "POST":
       // Add a Content-Type header (IMPORTANT!)
       $headers[] = "Content-Type: application/json";
       curl_setopt($curl, CURLOPT_POST, true);
       curl_setopt($curl, CURLOPT_POSTFIELDS, $payload);
       break;
     case "PATCH":
       // Add a Content-Type header (IMPORTANT!)
       $headers[] = "Content-Type: application/json";
       curl_setopt($curl, CURLOPT_CUSTOMREQUEST, "PATCH");
       curl_setopt($curl, CURLOPT_POSTFIELDS, $payload);
       break;
     case "DELETE":
       curl_setopt($curl, CURLOPT_CUSTOMREQUEST, "DELETE");
       break;
     default:
       error_log("INVALID METHOD: ".$method);
       exit;
   }
 
   curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
   curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
   $response = curl_exec($curl);
 
   $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
 
   if (self::isFailure($httpCode)) {
     return array('errorNumber' => $httpCode,
                  'error' => 'Request returned HTTP error '.$httpCode);
   }
 
   $curl_errno = curl_errno($curl);
   $curl_err = curl_error($curl);
 
   if ($curl_errno) {
     $msg = $curl_errno.": ".$curl_err;
     curl_close($curl);
     return array('errorNumber' => $curl_errno,
                  'error' => $msg);
   }
   else {
     curl_close($curl);
     return json_decode($response, true);
   }
 }
 

The first thing I want to bring attention to is the set of headers sent with every request. Matthias and I have both talked about this before, but it bears repeating. While Authorization and Accept are self-explanatory, the others are related to what we refer to as client instrumentation. Doing this should be considered a "must-have" when using the REST APIs. It can be invaluable if you run into errors.

The second thing is subtle but important. For POST and PATCH requests, you MUST set the Content-Type header to application/json. If you don't, you'll get an ErrorInvalidRequest error with the message "Cannot read the request body."

Creating Events

Ok, detour over, back to the Calendar API! The second use of the API is to create the event when the user clicks on the "Add to my calendar" button on the addToCalendar.php page. Clicking that button takes the user to doAdd.php, which does the actual adding. For this, I added the addEventToCalendar function to Office365Service.php:

  // Use the Calendar API to add an event to the default calendar.
 public static function addEventToCalendar($access_token, $subject, $location,
     $startTime, $endTime, $attendeeString) {
   // Create a static body.
   $htmlBody = "<html><body>Added by php-calendar app.</body></html>";
 
   // Generate the JSON payload
   $event = array(
     "Subject" => $subject,
     "Location" => array("DisplayName" => $location),
     "Start" => self::encodeDateTime($startTime),
     "End" => self::encodeDateTime($endTime),
     "Body" => array("ContentType" => "HTML", "Content" => $htmlBody)
   );
 
   if (!is_null($attendeeString) && strlen($attendeeString) > 0) {
     $attendeeAddresses = array_filter(explode(';', $attendeeString));
 
     $attendees = array();
     foreach($attendeeAddresses as $address) {
       error_log("Adding ".$address);
 
       $attendee = array(
         "EmailAddress" => array ("Address" => $address),
         "Type" => "Required"
       );
 
       $attendees[] = $attendee;
     }
 
     $event["Attendees"] = $attendees;
   }
 
   $eventPayload = json_encode($event);
 
   $createEventUrl = self::$outlookApiUrl."/Me/Events";
 
   $response = self::makeApiCall($access_token, "POST", $createEventUrl, $eventPayload);
 
   // If the call succeeded, the response should be a JSON representation of the
   // new event. Try getting the Id property and return it.
   if ($response['Id']) {
     return $response['Id'];
   }
 
   else {
     return $response;
   }
 }
 

Notice how easy PHP makes it to build the JSON payloads. All I need do is create an array and use json_encode to generate the payload. Very nice! Again this uses makeApiCall to send the request. We also don't need to worry about sending meeting invites. By adding attendees, the server takes care of that for us!

Adding an attachment

Remember before I said we'd get to the "voucher required" thing later? The voucher was really an excuse to add an attachment. If you add an event that requires a voucher to your calendar, the app will add the voucher as an attachment on the event. To do this, I added the addAttachmentToEvent function:

  // Use the Calendar API to add an attachment to an event.
 public static function addAttachmentToEvent($access_token, $eventId, $attachmentData) {
   // Generate the JSON payload
   $attachment = array(
     "@odata.type" => "#Microsoft.OutlookServices.FileAttachment",
     "Name" => "voucher.txt",
     "ContentBytes" => base64_encode($attachmentData)
   );
 
   $attachmentPayload = json_encode($attachment);
   error_log("ATTACHMENT PAYLOAD: ".$attachmentPayload);
 
   $createAttachmentUrl = self::$outlookApiUrl."/Me/Events/".$eventId."/Attachments";
 
   return self::makeApiCall($access_token, "POST", $createAttachmentUrl, 
     $attachmentPayload);
 }
 

The value of $attachmentData is the binary contents of the file to attach. In this case, it's a simple text file.

At this point you might be wondering: "Why not just include the attachment as part of the event when you created it? Wouldn't that be more efficient?" Well yes, it would, but it doesn't work! In order to add an attachment, you have to create the event first then POST to the event's Attachments collection.

The end result

If I stick with the premise of this being an experiment, then I have to conclude that PHP is a great language for calling the Office 365 REST APIs. Using just the built in libraries I was able to do everything I needed to do in a straightforward way. If PHP is your language of choice, you should have no trouble integrating with Office 365.

The sample app is available on GitHub. As always, I'd love to hear your feedback in the comments or on Twitter (@JasonJohMSFT).

Update: There's a great PHP tutorial for the Outlook API's available on https://dev.outlook.com.