Symfony Serializer: Denormalize (Deserialize) awkward array data - json

Using symfony/serializer version 4. The data I am getting back JSON from an API that looks like this
{
"name": "Steves Book Shop",
"book_1": "Lord of the Rings",
"book_2": "The Hobbit",
"book_[n]": "there can be any number of books"
}
I want to deserialize into the following models
class BookShop
{
protected $name;
/** #var Book[] */
protected $books;
public function getBooks(): array
{
return $this->books;
}
public function setBooks(array $books)
{
$this->books = $books;
}
public function addBook(Book $book)
{
$this->books[] = $book;
}
// ... other code removed to save space
}
class Book
{
protected $title;
// ... other code removed to save space
}
When using "cleaner" JSON below, everything works as expected and I get a BookShop with and array of Books returned.
{
"name": "Steves Book Shop",
"books": [
{ "title": "Lord of the Rings" },
{ "title": "The Hobbit" }
]
}
What would be a clean way to denormalize the original JSON which instead has the annoying book_1, book_2 etc.
I have been experimenting with a custom denormalizer (DenormalizerInterface) and my solution is looking much more difficult than you would expect.

Here is the solution I ended up with, I am not completely convinced this is the best way, but its working for the moment. Any feedback welcome:
class StrangeDataDenormalizer extends Symfony\Component\Serializer\Normalizer\ObjectNormalizer
{
protected function isStrangeDataInterface(string $type): bool
{
try {
$reflection = new \ReflectionClass($type);
return $reflection->implementsInterface(StrangeDataInterface::class);
} catch (\ReflectionException $e) { // $type is not always a valid class name, might have extract junk like `[]`
return false;
}
}
public function denormalize($data, $class, $format = null, array $context = array())
{
$normalizedData = $this->prepareForDenormalization($data);
$normalizedData = $class::prepareStrangeData($normalizedData);
return parent::denormalize($normalizedData, $class, $format, $context);
}
public function supportsDenormalization($data, $type, $format = null)
{
return $this->isStrangeDataInterface($type);
}
}
interface StrangeDataInterface
{
public static function prepareStrangeData($data): array;
}
class BookShop implements StrangeDataInterface
{
public static function prepareStrangeData($data): array
{
$preparedData = [];
foreach ($data as $key => $value) {
if (preg_match('~^book_[0-9]+$~', $key)) {
$preparedData['books'][] = ['title' => $value];
} else {
$preparedData[$key] = $value;
}
}
return $preparedData;
}
// .... other code hidden
}
function makeSerializer(): Symfony\Component\Serializer\Serializer
{
$extractor = new ReflectionExtractor();
$nameConverter = new CamelCaseToSnakeCaseNameConverter();
$arrayDenormalizer = new ArrayDenormalizer(); // seems to help respect the 'adder' typehints in the model. eg `addEmployee(Employee $employee)`
$strangeDataDenormalizer = new StrangeDataDenormalizer(
null,
$nameConverter,
null,
$extractor
);
$objectNormalizer = new ObjectNormalizer(
null,
$nameConverter,
null,
$extractor
);
$encoder = new JsonEncoder();
$serializer = new Symfony\Component\Serializer\Serializer(
[
$strangeDataDenormalizer,
$objectNormalizer,
$arrayDenormalizer,
],
[$encoder]
);
return $serializer;
}

You should use ArrayCollection for those books - assuming that those are just another entity on your application

Related

Symfony CollectionType and Doctrine : Remove null items

I'm having an issue with Symfony's CollectionType.
My project is an API, receiving form values as JSON input.
I want to be able to update an entity named "Reservation", which has a collection of items (called "ReservationItems", each item is an embedded form of type "ReservationItemType" :
class ReservationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// ...
->add(
'reservationItems',
CollectionType::class,
[
'entry_type' => ReservationItemType::class,
'allow_add' => true,
'allow_delete' => true,
'delete_empty' => true,
'by_reference' => false,
]
)
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Reservation::class,
]);
}
}
Here is the code of ReservationItemType :
class ReservationItemType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add(
'name',
TextType::class,
[
]
)
->add(
'status',
TextType::class,
[
]
)
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ReservationItem::class,
]);
}
}
Here is the Reservation entity definition :
#[ORM\Entity(repositoryClass: ReservationRepository::class)]
class Reservation
{
// ...
/** #var ReservationItem[]|ArrayCollection */
#[ORM\OneToMany(mappedBy: 'reservation', targetEntity: ReservationItem::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $reservationItems;
public function __construct()
{
$this->reservationItems = new ArrayCollection();
}
/**
* #return Collection<int, ReservationItem>
*/
public function getReservationItems(): Collection
{
return $this->reservationItems;
}
public function addReservationItem(ReservationItem $reservationItem): self
{
if (!$this->reservationItems->contains($reservationItem)) {
$this->reservationItems->add($reservationItem);
$reservationItem->setReservation($this);
}
return $this;
}
public function removeReservationItem(ReservationItem $reservationItem): self
{
if ($this->reservationItems->removeElement($reservationItem)) {
// set the owning side to null (unless already changed)
if ($reservationItem->getReservation() === $this) {
$reservationItem->setReservation(null);
}
}
return $this;
}
}
And here is the ReservationItem entity definition :
#[ORM\Entity(repositoryClass: ReservationItemRepository::class)]
class ReservationItem
{
// ...
#[ORM\ManyToOne(targetEntity: Reservation::class, inversedBy: 'reservationItems')]
#[ORM\JoinColumn(nullable: false)]
private ?Reservation $reservation = null;
public function getReservation(): ?Reservation
{
return $this->reservation;
}
public function setReservation(?Reservation $reservation): self
{
$this->reservation = $reservation;
return $this;
}
}
From my client app, here is what I am sending :
{
"reservation": {
"reservationItems": [
{ "name": "Foo", "status": "PENDING"},
null,
{ "name": "Bar", "status": "PENDING"}
]
}
]
In Symfony's doc, each example is based on form-data, which can be represented by :
reservation[reservationItems][0][name]: Foo
reservation[reservationItems][0][status]: PENDING
reservation[reservationItems][2][name]: Bar
reservation[reservationItems][2][status]: PENDING
This results in an indexed PHP array, with missing index "1".
Symfony considers former item which was indexed as 1 is missing, and delete this item.
But in JSON, it is not possible to manually index an array, with missing values.
My solution is to send null on indexes which must be deleted.
But Symfony is ignoring null values, and doesn't call Reservation::removeReservationItem method, even with by_reference set to false.
I'm using GraphQL, with webonyx/graphql-php. I tried another solution by sending an object instead of an array, but the library ignores "indexes"...
Did someone has met this issue (with GraphQL or REST or other JSON based protocol) ?
Do you have any advice ?
I have followed Symfony's recommandations by setting delete_empty to true, and even with required set to false or empty_data set to null, nothing works as expected.

Conflicting Laravel controllers- one can call method the other gives non-static error

I've taken over the code from another developer, and I'm quite confused and stuck: one controller I can call the class method CLASSS::method perfectly OK. the other Controller has a copy of the orginal code and modified. On the second one, I get the "non-static method" error.
Call Chain:
Controller->Class->filtered results->Controller response
1A) (Working) Conttroller
<?php
namespace App\Http\Controllers\V1;
use App\Site as SiteClass;
use Facades\App\Site;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
use App\{
Http\Controllers\Controller,
Http\Requests\SiteRequest,
Helper\ResourceTrait,
Assets,
Alerts,
Licensee,
Permits,
LandOwner,
Utility
};
use Illuminate\Support\Collection;
class SiteController extends Controller
{
private $obj;
public function __construct()
{
$this->obj = new SiteClass();
}
public function index(Request $request)
{
try
{
$data = Site::filter(
($request->has('sort')? $request->input('sort') : ''),
($request->has('filter')? $request->input('filter') : '')
);
...removed some extra code that's not relevant ...
return response($data);
}
catch(\Exception $e)
{
Log::info('Create exception from here?' . $e);
return response(array('error'=>$e->getMessage()),422);
}
} // index
....
}
1B) (Working) Class
<?php
namespace App;
use Illuminate\Support\Facades\Config;
use \App\BaseModel;
class Site extends BaseModel
{
protected $table = 'sites';
protected $fillable = [
"status","structureType","siteId","name","coverage","address","postCode", "subdistrict", "district", "region", "state", "country", "localCouncil", "latitude", "longitude", "dimensions",
"siteHandover", "startBilling", "utilityBillAcct", "utilityBillingAddress", "renewalTerm",
"capex", "opex", "siteManager", "siteManagerPhone", "siteManagerEmail", "siteOwnerManager", "siteOwnerManagerPhone", "siteOwnerManagerEmail"
];
...
// Working Static method call.
public function filter($sort = null, $search = null)
{
$data = $this;
// check if search variable not empty
if ($search != null)
{
$data = $data->where(function ($query) use ($search){
return $query->where($this->table.'.name','like','%'.$search.'%')
->orWhere($this->table.'.status','=',$search)
->orWhere($this->table.'.siteId','like','%'.$search.'%')
->orWhere($this->table.'.address','like','%'.$search.'%')
->orWhere($this->table.'.subdistrict','like','%'.$search.'%')
->orWhere($this->table.'.district','like','%'.$search.'%')
->orWhere($this->table.'.region','like','%'.$search.'%')
->orWhere($this->table.'.state','like','%'.$search.'%')
->orWhere($this->table.'.country','like','%'.$search.'%')
->orWhere($this->table.'.localCouncil','like','%'.$search.'%')
;
});
if ($sort != null)
{
$sorts = explode('|', $sort);
$data = $data->orderBy($sorts[0],$sorts[1]);
}
}
// check if sort variable not empty
if ($sort != null)
{
$sorts = explode('|', $sort);
$data = $data->orderBy($sorts[0],$sorts[1]);
}
else
{
$data = $data->orderBy('siteId','desc');
}
// return data
return $data->paginate(Config::get('api.records'));
}
}
2A) (Failing) Controller
<?php
namespace App\Http\Controllers\V1;
use App\{
Http\Controllers\Controller,
Helper\ResourceTrait,
Http\Requests\OrganisationRequest,
Organisation
};
use Illuminate\{
Http\Request,
Support\Facades\Config,
Support\Facades\Log
};
class OrganisationController extends Controller
{
private $org;
public function __construct()
{
$this->org = new Organisation();
}
public function index(Request $request)
{
try
{
//WORKAROUND: $this->org->... works
$data = Organisation::filter(
($request->has('sort')? $request->input('sort') : ''),
($request->has('filter')? $request->input('filter') : '')
); // FAILS with non-static method call error
return response($data);
}
catch(\Exception $e)
{
return response(array('error'=>$e->getMessage()),422);
}
} // index
}
...
2B) Failing Class
<?php
namespace App;
use Illuminate\Support\Facades\Config;
use \App\BaseModel;
class Organisation extends BaseModel
{
protected $table = 'organisation';
public function filter($sort = null, $search = null)
{
$data = $this;
// check if search variable not empty
if ($search != null)
{
$data = $data->where(function ($query) use ($search){
return $query->where($this->table.'.name','like','%'.$search.'%')
;
});
if ($sort != null)
{
$sorts = explode('|', $sort);
$data = $data->orderBy($sorts[0],$sorts[1]);
}
}
// check if sort variable not empty
if ($sort != null)
{
$sorts = explode('|', $sort);
$data = $data->orderBy($sorts[0],$sorts[1]);
}
else
{
$data = $data->orderBy('name');
}
// return data
return $data->paginate(Config::get('api.records'));
}
}
To my untutored eye, they look identical, yet one works and the other doesn't. Apologies in advance for the volume of code, but I don't know which parts are affecting what. I suspect it's somethng to do with an imported class, but I'm lost frankly :-D
Site has a Facade while Organisation does not.
Facades (from the docs) provide a "static" interface to classes that are available in the application's service container.

Symfony 2.7 dateTime deserialize

I need some help with SF 2.7 serializer
I have made an API with get Json Data like this :
{
"dateDebut":"2017-02-16",
"dateFin":"2018-02-16",
"caMoisTotalHorsSessions":"5.2",
"caMoisClients":"5.3",
"caMoisGarantie":"5.4",
"caMoisHuile":"5.5" }
I tried many way in order to deserialze into my object Class where dateDebut and dateFin are attending to be Datetime object and not string
try {
$encoder = new JsonEncoder();
$normalizer = new GetSetMethodNormalizer();
$callback = function ($date) {
return new \DateTime($date);
};
$normalizer->setCallbacks(array(
'dateDebut' => $callback,
'dateFin' => $callback, ));
$serializer = new Serializer(array($normalizer), array($encoder));
$entity = $serializer->deserialize($request->getContent(), $class, $format);
} catch (RuntimeException $e) {
return new JsonResponse(
['code' => Response::HTTP_BAD_REQUEST, 'message' => $this->trans('api.message.data_error')],
Response::HTTP_BAD_REQUEST);
}
But callbacks are never used :/ Could anyone help me please ?
Aim is to transform date string into Datetime object automatically before flush the object in database.
Thanks a lot
What you are trying to do is denormalization. The normalizer callbacks are for normalization. I think it's pretty confusing. It's strange that they would offer setting callback for just one direction.
I tested some code doing what I think you want to do. You need a custom normalizer class. The class is not so complicated, it can extend from the GetSetNormalizer or the ObjectNormalizer. You just want to create the \DateTime inside here, and you might add some validation for the date time.
class BoardNormalizer extends GetSetMethodNormalizer
{
public function denormalize($data, $class, $format = null, array $context = array())
{
if (isset($data['created'])) {
$data['created'] = new \DateTime($data['created']);
}
return parent::denormalize($data, $class, $format, $context);
}
}
I tested it with this code:
$json = json_encode([
'created' => '2017-02-20T05:49:51-0500'
]);
$encoder = new JsonEncoder();
$normalizer = new MyCustomNormalizer();
$serializer = new Serializer([$normalizer], [$encoder]);
$entity = $serializer->deserialize($json, MyCustomClass::class, 'json');
And it produced my custom class where the created property was a \DateTime object.
Aim is to transform date string into Datetime object automatically before flush the object in database.
Something like this? Using setters/getters ? I'm using the following code in entity
private $created;
public function setCreated($created)
{
if (!($created instanceof \DateTime)) {
$created = date_create($created);
}
$this->created = $created;
return $this;
}

Zend - Losing post values in Mapper

I seem to be losing my post data when passing it from my Controller to my Mapper to insert into my DB. I'm new to this and using the Guestbook template but modified it to be adding a "deal". This is what I have:
Controller
public function newAction()
{
$request = $this->getRequest();
$form = new Application_Form_Deal();
if ($this->getRequest()->isPost()) {
if ($form->isValid($request->getPost())) {
$posts = $form->getValues();
//print_r($posts);
$newdeal = new Application_Model_Deal($posts);
$mapper = new Application_Model_DealMapper();
$mapper->save($newdeal);
return $this->_helper->redirector('index');
}
}
$this->view->form = $form;
}
This is my Mapper
public function save(Application_Model_Deal $deal)
{
$data = array(
'dealname' => $deal->getDealName(),
'dealclient' => $deal->getDealClient(),
'dealtelephone' => $deal->getDealTelephone(),
'dealvalue' => $deal->getDealValue(),
'dealdescription' => $deal->getDealDescription(),
'dealcreated' => date('Y-m-d H:i:s'),
'dealmodified' => date('Y-m-d H:i:s'),
);
/*echo "<pre>";
print_r($data);
echo "</pre>";
exit();*/
if (null === ($dealid = $deal->getDealId())) {
unset($data['dealid']);
$this->getDbTable()->insert($data);
} else {
$this->getDbTable()->update($data, array('dealid = ?' => $dealid));
}
}
protected $_dealmodified;
protected $_dealcreated;
protected $_dealdescription;
protected $_dealvalue;
protected $_dealtelephone;
protected $_dealclient;
protected $_dealname;
protected $_dealid;
public function __construct(array $options = null)
{
if (is_array($options)) {
$this->setOptions($options);
}
}
public function __set($name, $value)
{
$method = 'set' . $name;
if (('mapper' == $name) || !method_exists($this, $method)) {
throw new Exception('Invalid deal property');
}
$this->$method($value);
}
public function __get($name)
{
$method = 'get' . $name;
if (('mapper' == $name) || !method_exists($this, $method)) {
throw new Exception('Invalid deal property');
}
return $this->$method($value);
}
public function setOptions(array $options)
{
$methods = get_class_methods($this);
foreach ($options as $key => $value) {
$method = 'set' . ucfirst($key);
if (in_array($method, $methods)) {
$this->$method($value);
}
}
return $this;
}
public function setDealModified($ts)
{
$this->_dealmodified = $ts;
return $this;
}
public function getDealModified()
{
return $this->_dealmodified;
}
public function setDealCreated($ts)
{
$this->_dealcreated = $ts;
return $this;
}
public function getDealCreated()
{
return $this->_dealcreated;
}
public function setDealDescription($text)
{
$this->_dealdescription = (string) $text;
return $this;
}
public function getDealDescription()
{
return $this->_dealdescription;
}
public function setDealValue( $text)
{
$this->_dealvalue = $text;
return $this;
}
public function getDealValue()
{
return $this->_dealvalue;
}
public function setDealTelephone($text)
{
$this->_dealtelephone = $text;
return $this;
}
public function getDealTelephone()
{
return $this->_dealtelephone;
}
public function setDealClient($text)
{
$this->_dealclient = $text;
return $this;
}
public function getDealClient()
{
return $this->_dealclient;
}
public function setDealName($text)
{
$this->_dealname = $text;
return $this;
}
public function getDealName()
{
return $this->_dealname;
}
public function setDealId($dealid)
{
$this->_dealid = (int) $dealid;
return $this;
}
public function getDealId()
{
// $this->_dealid = (int) $value;
return $this->_dealid;
}
I am a complete loss as to why, when I print_r my $data var in the Mapper everything is gone.
Please help!
This looks odd $mapper->save($newdeal, $posts);
your api is save(Application_Model_Deal $deal)
so it should read $mapper->save($newdeal);.
if you really want to get a handle on mappers and models, these three links helped me alot:
http://survivethedeepend.com/
http://phpmaster.com/building-a-domain-model/
http://phpmaster.com/integrating-the-data-mappers/
You are after a specific work flow :
present form
collect from data (the $post)
validate $post data (if(isValid())
get valid and filtered form values ($form->getValues())
instantiate your deal object ($newdeal = new Application_Model_Deal($posts);)
save/update your deal with your mapper
on success redirect
once your deal object is created the $post data has no further value and the deal object is the important thing to think about. Hope this helps a bit.
Ok I think I found it:
your problem revolves around this $method = 'set' . ucfirst($key); this line will try and call $this->setProperty(); your getters and setters are set up camel case so they won't work. The easiest way to fix this is to rename your getters and setters to:
public function setDealmodified($ts)//not camel cased only first letter is capped.
{
$this->_dealmodified = $ts;
return $this;
}
or you have to camel case your array keys.
Your save() method in the mapper does not have a second argument which you used as your $post parameter.
Refactor:
public function save(Application_Model_Deal $deal, array $post)...

How to serialize an anonymous type to JSON, when JSON *needs* properties with characters like dashes '-' e.t.c

I am building an app that uses Open Flash Chart 2. This chart is a flash object that accepts JSON with a specific structure.
"elements": [
{
"type": "bar_stack",
"colours": [
"#F19899",
"#A6CEE3"
],
"alpha": 1,
"on-show": {
"type": "grow-up",
"cascade": 1,
"delay": 0
},
...
I am using a simple anonymous type to return the JSON like so:
return Json(new
{
elements = new [] {
new
{
type = "bar_stack",
colours = colours.Take(variables.Count()),
alpha = 1,
on_show = new
{
type = "grow-up",
cascade = 1,
delay = 0
},
...
}
}
The problem is that several properties (like "on-show") use a dash and obviously I cannot use a dash when naming a property in C# code.
Is there a way to overcome this? Preferably without the need to declare a whole bunch of classes.
You can use a dictionary:
return Json(new {
elements = new [] {
new Dictionary<string, object>
{
{ "type", "bar_stack" },
{ "colours", new [] { "#F19899", "#A6CEE3" } },
{ "alpha", 1 },
{ "on-show", new
{
type = "grow-up",
cascade = 1,
delay = 0
} },
}
}
});
(Written in SO editor; I may have made some syntax errors, but you get the idea....)
Craig's solution is propably better, but in the meantime I implemented this:
public class UnderscoreToDashAttribute : ActionFilterAttribute
{
private readonly string[] _fixes;
public UnderscoreToDashAttribute(params string[] fixes)
{
_fixes = fixes;
}
public override void OnResultExecuting(ResultExecutingContext filterContext)
{
filterContext.HttpContext.Response.Filter = new ReplaceFilter(filterContext, s => _fixes.Aggregate(s, (current, fix) => current.Replace(fix, fix.Replace('_', '-'))));
}
public class ReplaceFilter : MemoryStream
{
private readonly Stream _stream;
private readonly Func<string, string> _filter;
public ReplaceFilter(ControllerContext filterContext, Func<string, string> filter)
{
_stream = filterContext.HttpContext.Response.Filter;
_filter = filter;
}
public override void Write(byte[] buffer, int offset, int count)
{
// capture the data and convert to string
var data = new byte[count];
Buffer.BlockCopy(buffer, offset, data, 0, count);
var s = _filter(Encoding.Default.GetString(buffer));
// write the data to stream
var outdata = Encoding.Default.GetBytes(s);
_stream.Write(outdata, 0, outdata.GetLength(0));
}
}
}
Then, if you decorate your action like so:
[UnderscoreToDash("on_show", "grid_colour")]
public JsonResult GetData()
It makes the appropriate "fixes".
P.S. That awesome moment when Resharper changes your code to Linq...
_fixes.Aggregate(s, (current, fix) => current.Replace(fix, fix.Replace('_', '-')))