How I integrated myFMApiLibrary for PHP with Symfony 3.4

Lesterius recently published the myFMApiLibrary for PHP. The myFMApiLibrary for PHP is a REST client library for PHP that allows you to easily interact with the FileMaker Data API and integrate it faster. But how do you use it?

In this article I will show you how I use it with Symfony 3.4, a popular Web Application framework based on a set of PHP Components.

Feel free to improve it.

Prerequisites

This article assumes that you already have experience with Symfony.

Before using the DataApi from FileMaker, make sure that your FileMaker Server has a proper SSL certificate installed.

First you need to get the library using composer:

composer require myfmbutler/myfmapilibrary-for-php

You will also need JMS serializer library (follow documentation found here) and doctrine annotations to be able to link FileMaker fields with your PHP properties:

composer require jms/serializer
composer require doctrine/doctrine-bundle

Then create 4 parameters in parameters.yml file: database_api_url, database_name, database_user and database_password.

Create services

Now that everything is fine, we’ll create 2 services in services.yml:

  • One to set the myFMAPI library
  • The other one to set-up the Event Subscriber.

The first one needed is used to set-up the myFMAPI library:

Lesterius\FileMakerApi\DataApi:
arguments:
['%database_api_url%','%database_name%', null, null, true]

The second one is used to set-up the connection to the DataApi to be able to use our first service:

AppBundle\EventSubscriber\RequestEventSubscriber:
arguments:
['@Lesterius\FileMakerApi\DataApi', '%database_user%', '%database_password%']

Here follows the class RequestEventSubscriber.php that must be put in src/AppBundle/EventSubscriber repository:

<?php

namespace
AppBundle\EventSubscriber;

use Lesterius\FileMakerApi\DataApi;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class RequestEventSubscriber implements EventSubscriberInterface
{
/**
*
@var string
*/
private $apiUser;
/**
*
@var string
*/
private $apiPassword;
/**
*
@var DataApi
*/
private $dataApi;

public function __construct(DataApi $dataApi, string $apiUser, string $apiPassword)
{
$this->apiUser = $apiUser;
$this->apiPassword = $apiPassword;
$this->dataApi = $dataApi;
}

/**
* Returns an array of event names this subscriber wants to listen to.
*
* The array keys are event names and the value can be:
*
* * The method name to call (priority defaults to 0)
* * An array composed of the method name to call and the priority
* * An array of arrays composed of the method names to call and respective
* priorities, or 0 if unset
*
* For instance:
*
* * array('eventName' => 'methodName')
* * array('eventName' => array('methodName', $priority))
* * array('eventName' => array(array('methodName1', $priority), array('methodName2')))
*
*
@return array The event names to listen to
*/
public static function getSubscribedEvents()
{
return [
KernelEvents::REQUEST => ['apiLogin']
];
}

/**
*
@param GetResponseEvent $event
*
*
@throws \AppBundle\Service\FileMakerApi\Exception\Exception
*/
public function apiLogin(GetResponseEvent $event)
{
if (is_null($this->dataApi->getApiToken())) {
$this->dataApi->login($this->apiUser, $this->apiPassword);
}
}
}

Create entities

With the old FileMaker Php library I used to create a table with the field names and the property names. In Symfony, I can use annotations to link fields with properties.

An example of using annotations

Usually you need a lot of entities. I think it is best to make an abstract class for all of them. In this class, I create methods to manage the internal FileMaker id and to get the field names found in FileMaker.

Here follows my AbstractEntity.php in src/AppBundle/Entity:

<?php

namespace
AppBundle\Entity;

use Doctrine\Common\Annotations\AnnotationReader;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Serializer\Normalizer\DataUriNormalizer;

/**
* Class AbstractEntity
*
*
@package AppBundle\Entity
*/

abstract class AbstractEntity
{
/**
*
@Serializer\SerializedName("recordId")
*
@Serializer\Type("integer")
*
@Serializer\Groups({"internal"})
*/
public $idRecordFileMaker;

/**
*
@Serializer\SerializedName("modId")
*
@Serializer\Type("integer")
*
@Serializer\Groups({"internal"})
*/
public $idModificationFileMaker;

const SEPARATOR = "\r";

/**
*
@return int
*/
public function getInternalFmId()
{
return $this->idRecordFileMaker;
}



/**
*
@param int $idRecordFileMaker
*/
public function setInternalFmId($idRecordFileMaker)
{
$this->idRecordFileMaker = $idRecordFileMaker;
}


/**
* Get the name of Annotation Name with the property name
*
*
@param $name
*
@return mixed
*
@throws \Doctrine\Common\Annotations\AnnotationException
*
@throws \ReflectionException
*/
public static function getSerializedNameByPropertyName($name)
{
$reflectionClass = new \ReflectionClass(get_called_class());
$property = $reflectionClass->getProperty($name);

$annotationReader = new AnnotationReader();
$classAnnotations = $annotationReader->getPropertyAnnotation(
$property,
'JMS\Serializer\Annotation\SerializedName'
);

return $classAnnotations->name;
}
}

Now in my entity, I’ll need to use:

use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;

Every class that extends the AbstractEntity Class will need an “IdNameClass” property. The name of the property must start with “id”, followed by the name of the class in camel case, otherwise it will not work. As an example if I have Class Attendee, I’ll need a property with the name $idAttendee.

Next, I’ll put my FileMaker field names in “Serializer\SerializedName” annotations.

In “Serializer\Type” we store the data type. Here‘s the types you can find:

  • int
  • string
  • Datetime (with UTC format)
  • boolean

In “Serializer\Groups” you define the actions that can be done on the field:

  • create
  • update
  • internal
  • excluded
  • default

These groups are managed in the AbstractRepository class. The “default” group is set if you didn’t define it.

The group will be used when needed, in a “create” case to insert some data you choose not to update, or in an “internal” case where you only need to retrieve data (i.e. modification timestamps).

A example of using Types and Groups

Creating Repositories

You need to create a repository for your entity. Like entities, I made a AbstractRepository to simulate a database call, similar to what I did for Doctrine.

For my own reading comfort I prefer writing all my database functions in my repositories files.
An example of repository with a script call and an upload to container instruction
A simple example of findBy
A complexe example of findBy with OMIT

Here follows my AbstractRepository.php in src/AppBundle/Repository:

<?php

namespace
AppBundle\Repository;

use JMS\Serializer\SerializationContext;
use JMS\Serializer\SerializerBuilder;
use Lesterius\FileMakerApi\DataApi;
use Psr\Http\Message\ResponseInterface;

abstract class AbstractRepository
{
const SORT_ASC = 'ascend';
const SORT_DESC = 'descend';
const BIG_RANGE_VALUE = '10000';
const OMIT = 'omit';

protected $em;
protected $entityName;
protected $layout;
protected $serializer;

/**
* AbstractRepository constructor
*
*
@param DataApi $em
*
@param $entityName
*/
public function __construct(DataApi $em, $entityName, $layout)
{
$this->em = $em;
$this->entityName = $entityName;
$this->layout = $layout;
}

/**
* Get entity name
*
*
@return mixed
*/
protected function getEntityName()
{
return $this->entityName;
}

/**
* Hydrate a list of Objects
*
*
@param array $data
*
*
@return array
*
@throws \Exception
*/
protected function hydrateListObjects(array $data)
{
$list_objects = [];

foreach ($data as $record) {
$list_objects[] = $this->hydrateObject($record);
}

return $list_objects;
}

/**
*
@param array $data
*
*
@return object
*
@throws \Exception
*/
protected function hydrateObject(array $data)
{
$data = $this->prepareDataForObject($data);

if (is_null($this->serializer)) {
$this->serializer = SerializerBuilder::create()->build();
}
//--

$object = $this->serializer->deserialize(json_encode($data), $this->getEntityName(), 'json');

return $object;
}

/**
*
*
@param array $data
*
*
@return array
*
@throws \Exception
*/
private function prepareDataForObject(array $data)
{
$result = [];

$result['recordId'] = (isset($data['recordId']) ? $data['recordId'] : null);
$result['modId'] = (isset($data['modId']) ? $data['modId'] : null);

if (isset($data['fieldData'])) {
$result = array_merge($result, $data['fieldData']);
if (isset($data['portalData']) && !empty($data['portalData'])) {
$result = array_merge($result, $data['portalData']);
}
}else {
$result = array_merge($result, $data);
}

return $result;
}

/**
* Search by Array
*
*
@param array $criterions
*
@param array $sortArray
*
*
@param null $offset
*
@param null $range
*
@param null $portal
*
*
@return array|Object
*
@throws \Exception
*/
public function findBy(array $criterions = [], array $sortArray = [], $offset = null, $range = null, $portal = [])
{
$sort = null;

$preparedQuery = $this->prepareFindCriterions($criterions);

if (!empty($sortArray)) {
foreach ($sortArray as $fieldName => $sortOrder) {
$sort[] = ['fieldName' => $fieldName, 'sortOrder' => $sortOrder];
}
}

if (is_null($range)) {
$range = self::BIG_RANGE_VALUE;
}

$results = $this->em->findRecords($this->layout, $preparedQuery, $sort, $offset, $range, $portal);

$return = $this->hydrateListObjects($results);

return $return;
}

/**
* Find All records
*
*
@return array
*
@throws \Exception
*/
public function findAll()
{
$results = $this->em->getRecords($this->layout);
$return = $this->hydrateListObjects($results);

return $return;
}

/**
* Search by ID
*
*
@return Object|array
*
@throws \Exception
*/
public function find($idObject)
{
$propertyName = 'id'.str_replace('AppBundle\Entity\\', '', $this->entityName);
$annotationName = call_user_func($this->entityName.'::getSerializedNameByPropertyName', $propertyName);
$criterions = $this->prepareFindCriterions([$annotationName => $idObject]);

$results = $this->em->findRecords($this->layout, $criterions);
$return = $this->hydrateListObjects($results);

if (isset($return[0])) {
return $return[0];
}

return null;
}

/**
*
* Create objet
*
*
@param $object
*
*
@return object
*
*
@throws \Exception
*/
public function create($object)
{
if ($object instanceof $this->entityName) {
$serializer = SerializerBuilder::create()->build();
$data = $serializer->serialize($object, 'json',
SerializationContext::create()->setGroups(['Default', 'create']));
$data = json_decode($data, true);

$recordId = $this->em->createRecord($this->layout, $data);

if ($recordId instanceof ResponseInterface) {
throw new \Exception('Error, creation fail : '.$recordId);
}

$results = $this->em->getRecord($this->layout, $recordId);
$return = $this->hydrateObject($results);

return $return;
}

throw new \Exception('Error, object is not a instance of the object repository');
}

/**
* Edit object
*
*
@param $object
*
*
@param array $scripts
*
@return object
*
*
@throws \AppBundle\Service\FileMakerApi\Exception\Exception
*
@throws \Exception
*/
public function set($object, $scripts = [])
{
if ($object instanceof $this->entityName && !empty($object->{'getInternalFmId'}())) {
$serializer = SerializerBuilder::create()->build();
$data = $serializer->serialize($object, 'json',
SerializationContext::create()->setGroups(['Default', 'update']));
$data = json_decode($data, true);

$modId = $this->em->editRecord($this->layout, $object->{'getInternalFmId'}(), $data, null, [], $scripts);

if ($modId instanceof ResponseInterface) {
throw new \Exception('Error, update fail : '.$object->{'getInternalFmId'}());
}

$results = $this->em->getRecord($this->layout, $object->{'getInternalFmId'}());
$return = $this->hydrateObject($results);

return $return;
}

throw new \Exception('Error, object is not a instance of the object repository');
}

/**
*
@param $object
*
@param array $fileOption
*
@param int $repetition
*
@return object
*
@throws \AppBundle\Service\FileMakerApi\Exception\Exception
*
@throws \Exception
*/
public function setFile($object, $fileOption = [], $repetition = 1)
{
if ($object instanceof $this->entityName && !empty($object->{'getInternalFmId'}()) && !empty($fileOption)) {
foreach ($fileOption as $fieldName => $filePath) {
$modId = $this->em->uploadToContainer($this->layout, $object->{'getInternalFmId'}(), $fieldName, $repetition, $filePath);
if ($modId instanceof ResponseInterface) {
throw new \Exception('Error, update fail : '.$object->{'getInternalFmId'}());
}
}

$results = $this->em->getRecord($this->layout, $object->{'getInternalFmId'}());
$return = $this->hydrateObject($results);

return $return;
}
}

/**
*
@param array $criterions
*
*
@return array
*/
private function prepareFindCriterions(array $criterions)
{
$preparedCriterions = [];
foreach ($criterions as $index => $criterion) {

if (is_array($criterion)) {
$fields = [];

foreach ($criterion as $field => $value) {
$fields[] = ['fieldname' => $field, 'fieldvalue' => $value];
}

$preparedCriterions[]['fields'] = $fields;
} else {
$fields[] = ['fieldname' => $index, 'fieldvalue' => $criterion];
}
}

if (empty($preparedCriterions) && !empty($criterions)) {
$preparedCriterions[]['fields'] = $fields;
}

return $preparedCriterions;
}

/**
*
@return DataApi
*/
public function getEm(): DataApi
{
return $this->em;
}

/**
*
@param DataApi $em
*/
public function setEm(DataApi $em)
{
$this->em = $em;
}

/**
*
@return mixed
*/
public function getLayout()
{
return $this->layout;
}

/**
*
@param mixed $layout
*/
public function setLayout($layout)
{
$this->layout = $layout;
}

/**
*
@return mixed
*/
public function getSerializer()
{
return $this->serializer;
}

/**
*
@param mixed $serializer
*/
public function setSerializer($serializer)
{
$this->serializer = $serializer;
}
}

How to use it?

I created 2 functions in my BaseController.php:

  • one to call the myFMApiLibrary-for-PHP service and connect to the database.
  • one to close the connection (don’t forget this part!).

Then in my controller, I can create my repository object and my object:

An example of using with a creation, an upload to container and a script execution

What about error messages?

The error messages you could have should be explicit (and with their FileMaker error code). DataApi works the same way as another FileMaker Client. As an example, two users can’t edit the same record at the same time, and will throw an error.

Conclusion

Follow these steps, and you will be able to easily connect your Symfony to the FileMaker 17 DataAPI (with myFMApiLibrary for PHP), and significantly save your work time, which is good for you, but even better for your clients!

What were your thoughts on this tutorial? Please let me know in the comments.

Don’t forget to subscribe to follow our reviews and updates of this article once the FileMaker 18 DataAPI will be released.

Some Tips

If you choose “int” as “Serializer\Type” annotation, be sure the field is always filled. “Empty” is not an int nor a boolean, change to “string” to avoid this.

@Serializer\Type(“DateTime<’m/d/Y’, ‘Europe/Paris’>”) can be @Serializer\Type(“DateTime<’m/d/Y H:i:s’, ‘Europe/Paris’>”), for example.

If you don’t put @Serializer annotation, the property won’t be linked to FileMaker.

And please, don’t forget to set stricter privileges to your DataApi user for more security.