1<?php
2/**
3 * Copyright 2017 Facebook, Inc.
4 *
5 * You are hereby granted a non-exclusive, worldwide, royalty-free license to
6 * use, copy, modify, and distribute this software in source code or binary
7 * form for use in connection with the web services and APIs provided by
8 * Facebook.
9 *
10 * As with any software that integrates with the Facebook platform, your use
11 * of this software is subject to the Facebook Developer Principles and
12 * Policies [http://developers.facebook.com/policy/]. This copyright notice
13 * shall be included in all copies or substantial portions of the software.
14 *
15 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
18 * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
21 * DEALINGS IN THE SOFTWARE.
22 *
23 */
24namespace Facebook;
25
26use ArrayIterator;
27use IteratorAggregate;
28use ArrayAccess;
29use Facebook\Authentication\AccessToken;
30use Facebook\Exceptions\FacebookSDKException;
31
32/**
33 * Class BatchRequest
34 *
35 * @package Facebook
36 */
37class FacebookBatchRequest extends FacebookRequest implements IteratorAggregate, ArrayAccess
38{
39    /**
40     * @var array An array of FacebookRequest entities to send.
41     */
42    protected $requests;
43
44    /**
45     * @var array An array of files to upload.
46     */
47    protected $attachedFiles;
48
49    /**
50     * Creates a new Request entity.
51     *
52     * @param FacebookApp|null        $app
53     * @param array                   $requests
54     * @param AccessToken|string|null $accessToken
55     * @param string|null             $graphVersion
56     */
57    public function __construct(FacebookApp $app = null, array $requests = [], $accessToken = null, $graphVersion = null)
58    {
59        parent::__construct($app, $accessToken, 'POST', '', [], null, $graphVersion);
60
61        $this->add($requests);
62    }
63
64    /**
65     * Adds a new request to the array.
66     *
67     * @param FacebookRequest|array $request
68     * @param string|null|array     $options Array of batch request options e.g. 'name', 'omit_response_on_success'.
69     *                                       If a string is given, it is the value of the 'name' option.
70     *
71     * @return FacebookBatchRequest
72     *
73     * @throws \InvalidArgumentException
74     */
75    public function add($request, $options = null)
76    {
77        if (is_array($request)) {
78            foreach ($request as $key => $req) {
79                $this->add($req, $key);
80            }
81
82            return $this;
83        }
84
85        if (!$request instanceof FacebookRequest) {
86            throw new \InvalidArgumentException('Argument for add() must be of type array or FacebookRequest.');
87        }
88
89        if (null === $options) {
90            $options = [];
91        } elseif (!is_array($options)) {
92            $options = ['name' => $options];
93        }
94
95        $this->addFallbackDefaults($request);
96
97        // File uploads
98        $attachedFiles = $this->extractFileAttachments($request);
99
100        $name = isset($options['name']) ? $options['name'] : null;
101
102        unset($options['name']);
103
104        $requestToAdd = [
105            'name' => $name,
106            'request' => $request,
107            'options' => $options,
108            'attached_files' => $attachedFiles,
109        ];
110
111        $this->requests[] = $requestToAdd;
112
113        return $this;
114    }
115
116    /**
117     * Ensures that the FacebookApp and access token fall back when missing.
118     *
119     * @param FacebookRequest $request
120     *
121     * @throws FacebookSDKException
122     */
123    public function addFallbackDefaults(FacebookRequest $request)
124    {
125        if (!$request->getApp()) {
126            $app = $this->getApp();
127            if (!$app) {
128                throw new FacebookSDKException('Missing FacebookApp on FacebookRequest and no fallback detected on FacebookBatchRequest.');
129            }
130            $request->setApp($app);
131        }
132
133        if (!$request->getAccessToken()) {
134            $accessToken = $this->getAccessToken();
135            if (!$accessToken) {
136                throw new FacebookSDKException('Missing access token on FacebookRequest and no fallback detected on FacebookBatchRequest.');
137            }
138            $request->setAccessToken($accessToken);
139        }
140    }
141
142    /**
143     * Extracts the files from a request.
144     *
145     * @param FacebookRequest $request
146     *
147     * @return string|null
148     *
149     * @throws FacebookSDKException
150     */
151    public function extractFileAttachments(FacebookRequest $request)
152    {
153        if (!$request->containsFileUploads()) {
154            return null;
155        }
156
157        $files = $request->getFiles();
158        $fileNames = [];
159        foreach ($files as $file) {
160            $fileName = uniqid();
161            $this->addFile($fileName, $file);
162            $fileNames[] = $fileName;
163        }
164
165        $request->resetFiles();
166
167        // @TODO Does Graph support multiple uploads on one endpoint?
168        return implode(',', $fileNames);
169    }
170
171    /**
172     * Return the FacebookRequest entities.
173     *
174     * @return array
175     */
176    public function getRequests()
177    {
178        return $this->requests;
179    }
180
181    /**
182     * Prepares the requests to be sent as a batch request.
183     */
184    public function prepareRequestsForBatch()
185    {
186        $this->validateBatchRequestCount();
187
188        $params = [
189            'batch' => $this->convertRequestsToJson(),
190            'include_headers' => true,
191        ];
192        $this->setParams($params);
193    }
194
195    /**
196     * Converts the requests into a JSON(P) string.
197     *
198     * @return string
199     */
200    public function convertRequestsToJson()
201    {
202        $requests = [];
203        foreach ($this->requests as $request) {
204            $options = [];
205
206            if (null !== $request['name']) {
207                $options['name'] = $request['name'];
208            }
209
210            $options += $request['options'];
211
212            $requests[] = $this->requestEntityToBatchArray($request['request'], $options, $request['attached_files']);
213        }
214
215        return json_encode($requests);
216    }
217
218    /**
219     * Validate the request count before sending them as a batch.
220     *
221     * @throws FacebookSDKException
222     */
223    public function validateBatchRequestCount()
224    {
225        $batchCount = count($this->requests);
226        if ($batchCount === 0) {
227            throw new FacebookSDKException('There are no batch requests to send.');
228        } elseif ($batchCount > 50) {
229            // Per: https://developers.facebook.com/docs/graph-api/making-multiple-requests#limits
230            throw new FacebookSDKException('You cannot send more than 50 batch requests at a time.');
231        }
232    }
233
234    /**
235     * Converts a Request entity into an array that is batch-friendly.
236     *
237     * @param FacebookRequest   $request       The request entity to convert.
238     * @param string|null|array $options       Array of batch request options e.g. 'name', 'omit_response_on_success'.
239     *                                         If a string is given, it is the value of the 'name' option.
240     * @param string|null       $attachedFiles Names of files associated with the request.
241     *
242     * @return array
243     */
244    public function requestEntityToBatchArray(FacebookRequest $request, $options = null, $attachedFiles = null)
245    {
246
247        if (null === $options) {
248            $options = [];
249        } elseif (!is_array($options)) {
250            $options = ['name' => $options];
251        }
252
253        $compiledHeaders = [];
254        $headers = $request->getHeaders();
255        foreach ($headers as $name => $value) {
256            $compiledHeaders[] = $name . ': ' . $value;
257        }
258
259        $batch = [
260            'headers' => $compiledHeaders,
261            'method' => $request->getMethod(),
262            'relative_url' => $request->getUrl(),
263        ];
264
265        // Since file uploads are moved to the root request of a batch request,
266        // the child requests will always be URL-encoded.
267        $body = $request->getUrlEncodedBody()->getBody();
268        if ($body) {
269            $batch['body'] = $body;
270        }
271
272        $batch += $options;
273
274        if (null !== $attachedFiles) {
275            $batch['attached_files'] = $attachedFiles;
276        }
277
278        return $batch;
279    }
280
281    /**
282     * Get an iterator for the items.
283     *
284     * @return ArrayIterator
285     */
286    public function getIterator()
287    {
288        return new ArrayIterator($this->requests);
289    }
290
291    /**
292     * @inheritdoc
293     */
294    public function offsetSet($offset, $value)
295    {
296        $this->add($value, $offset);
297    }
298
299    /**
300     * @inheritdoc
301     */
302    public function offsetExists($offset)
303    {
304        return isset($this->requests[$offset]);
305    }
306
307    /**
308     * @inheritdoc
309     */
310    public function offsetUnset($offset)
311    {
312        unset($this->requests[$offset]);
313    }
314
315    /**
316     * @inheritdoc
317     */
318    public function offsetGet($offset)
319    {
320        return isset($this->requests[$offset]) ? $this->requests[$offset] : null;
321    }
322}
323