├── .php-cs-fixer.dist.php ├── LICENCE ├── composer.json ├── phpstan.neon └── src ├── AirtableClient.php ├── AirtableClientBundle.php ├── AirtableClientInterface.php ├── AirtableRecord.php ├── AirtableTransport.php ├── AirtableTransportInterface.php ├── DependencyInjection ├── AirtableClientExtension.php └── Configuration.php ├── Exception └── MissingRecordDataException.php └── Resources └── config └── services.php /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 8 | ->exclude(['.github', 'docs', 'var', 'vendor']) 9 | ; 10 | 11 | return (new PhpCsFixer\Config) 12 | ->setRules([ 13 | '@PSR1' => true, 14 | '@PSR2' => true, 15 | '@PhpCsFixer' => true, 16 | '@Symfony' => true, 17 | '@DoctrineAnnotation' => true, 18 | '@PHP74Migration' => true, 19 | 'strict_param' => true, 20 | 'strict_comparison' => false, 21 | 'array_syntax' => ['syntax' => 'short'], 22 | 'array_indentation' => true, 23 | 'ordered_imports' => true, 24 | 'protected_to_private' => true, 25 | 'declare_strict_types' => true, 26 | 'native_function_invocation' => [ 27 | 'include' => ['@compiler_optimized'], 28 | 'scope' => 'namespaced', 29 | 'strict' => true, 30 | ], 31 | 'mb_str_functions' => true, 32 | 'linebreak_after_opening_tag' => true, 33 | 'combine_consecutive_issets' => true, 34 | 'combine_consecutive_unsets' => true, 35 | 'compact_nullable_typehint' => true, 36 | 'no_superfluous_elseif' => true, 37 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 38 | 'phpdoc_order' => true, 39 | 'pow_to_exponentiation' => true, 40 | 'simplified_null_return' => true, 41 | 'header_comment' => [ 42 | 'header' => $header, 43 | ], 44 | 'align_multiline_comment' => [ 45 | 'comment_type' => 'all_multiline', 46 | ], 47 | 'php_unit_test_annotation' => [ 48 | 'style' => 'annotation', 49 | ], 50 | 'php_unit_test_case_static_method_calls' => true, 51 | 'method_chaining_indentation' => false, 52 | 'php_unit_expectation' => true, 53 | 'php_unit_test_class_requires_covers' => false, 54 | 'global_namespace_import' => [ 55 | 'import_classes' => true, 56 | 'import_constants' => true, 57 | 'import_functions' => true, 58 | ], 59 | ]) 60 | ->setRiskyAllowed(true) 61 | ->setUsingCache(true) 62 | ->setFinder($finder) 63 | ; 64 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yoan Bernabeu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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 THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yoanbernabeu/airtable-client-bundle", 3 | "description": "Simple Client for Airtable API", 4 | "type": "symfony-bundle", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Yoan Bernabeu", 9 | "email": "yoan.bernabeu@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.4.3", 14 | "symfony/http-client": "^5.0|^6.0", 15 | "symfony/config": "^5.0|^6.0", 16 | "symfony/dependency-injection": "^5.0|^6.0", 17 | "symfony/http-kernel": "^5.0|^6.0", 18 | "symfony/serializer": "^5.0|^6.0", 19 | "symfony/property-access": "^5.0|^6.0", 20 | "symfony/form": "^5.0|^6.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Yoanbernabeu\\AirtableClientBundle\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Yoanbernabeu\\AirtableClientBundle\\Tests\\": "tests/" 30 | } 31 | }, 32 | "require-dev": { 33 | "ext-json": "*", 34 | "friendsofphp/php-cs-fixer": "^3.0", 35 | "phpstan/phpstan": "^1.0", 36 | "phpstan/phpstan-beberlei-assert": "^1.0", 37 | "phpstan/phpstan-deprecation-rules": "^1.0", 38 | "phpstan/phpstan-phpunit": "^1.0", 39 | "phpstan/phpstan-strict-rules": "^1.0", 40 | "symfony/framework-bundle": "^5.0|^6.0", 41 | "symfony/phpunit-bridge": "^5.0|^6.0" 42 | }, 43 | "scripts": { 44 | "test:phpstan": "./vendor/bin/phpstan analyse --error-format=checkstyle | cs2pr", 45 | "test:cs": "./vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --using-cache=no" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - src 5 | ignoreErrors: 6 | - 7 | message: '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\)#' 8 | count: 1 9 | path: src/DependencyInjection/Configuration.php 10 | - 11 | message: '#Short ternary operator is not allowed. Use null coalesce operator if applicable or consider using long ternary#' 12 | count: 1 13 | path: src/AirtableClientBundle.php 14 | 15 | checkMissingIterableValueType: false 16 | checkGenericClassInNonGenericObjectType: false 17 | includes: 18 | - vendor/phpstan/phpstan-strict-rules/rules.neon 19 | - vendor/phpstan/phpstan-phpunit/extension.neon 20 | - vendor/phpstan/phpstan-phpunit/rules.neon 21 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon 22 | - vendor/phpstan/phpstan-beberlei-assert/extension.neon 23 | -------------------------------------------------------------------------------- /src/AirtableClient.php: -------------------------------------------------------------------------------- 1 | airtableTransport = $airtableTransport; 22 | $this->normalizer = $normalizer; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function findAll(string $table, ?string $view = null, ?string $dataClass = null): array 29 | { 30 | $url = sprintf( 31 | '%s%s', 32 | $table, 33 | null !== $view ? '?view='.$view : '' 34 | ); 35 | 36 | $response = $this->pagination($url, $this->airtableTransport->request('GET', $url)->toArray()); 37 | 38 | return $this->mapRecordsToAirtableRecords($response['records'], $dataClass); 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function pagination(string $url, array $response): array 45 | { 46 | if ($response['offset'] ?? null) { 47 | $param = mb_stristr($url, '?view') ? '&' : '?'; 48 | 49 | $offsetUrl = $url.$param.'offset='.$response['offset']; 50 | $offsetResponse = $this->airtableTransport->request('GET', $offsetUrl)->toArray(); 51 | $response = array_merge($response['records'], $offsetResponse['records']); 52 | 53 | return $this->pagination($url, ['records' => $response, 'offset' => $offsetResponse['offset'] ?? null]); 54 | } 55 | 56 | return $response; 57 | } 58 | 59 | /** 60 | * {@inheritdoc} 61 | */ 62 | public function findBy(string $table, string $field, string $value, ?string $dataClass = null): array 63 | { 64 | $filterByFormula = sprintf("?filterByFormula=AND({%s} = '%s')", $field, $value); 65 | $url = sprintf('%s%s', $table, $filterByFormula); 66 | $response = $this->airtableTransport->request('GET', $url); 67 | 68 | return $this->mapRecordsToAirtableRecords($response->toArray()['records'], $dataClass); 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | */ 74 | public function find(string $table, string $id, ?string $dataClass = null): ?AirtableRecord 75 | { 76 | $url = sprintf('%s/%s', $table, $id); 77 | $response = $this->airtableTransport->request('GET', $url); 78 | 79 | $recordData = $response->toArray(); 80 | 81 | $recordData = $this->createRecordFromResponse($dataClass, $recordData); 82 | 83 | return AirtableRecord::createFromRecord($recordData); 84 | } 85 | 86 | /** 87 | * {@inheritdoc} 88 | */ 89 | public function findLast(string $table, $field, ?string $dataClass = null): ?AirtableRecord 90 | { 91 | $params = [ 92 | 'pageSize' => 1, 93 | 'sort' => [ 94 | 0 => [ 95 | 'field' => $field, 96 | 'direction' => 'desc', 97 | ], 98 | ], 99 | ]; 100 | $url = sprintf( 101 | '%s?%s', 102 | $table, 103 | http_build_query($params) 104 | ); 105 | $response = $this->airtableTransport->request('GET', $url); 106 | 107 | $recordData = $response->toArray()['records'][0]; 108 | 109 | if (!$recordData) { 110 | return null; 111 | } 112 | 113 | $recordData = $this->createRecordFromResponse($dataClass, $recordData); 114 | 115 | return AirtableRecord::createFromRecord($recordData); 116 | } 117 | 118 | /** 119 | * {@inheritdoc} 120 | */ 121 | public function add(string $table, array $fields, ?string $dataClass = null): ?AirtableRecord 122 | { 123 | $url = sprintf('%s', $table); 124 | 125 | return $this->createOrUpdateRecord('POST', $url, $fields, $dataClass); 126 | } 127 | 128 | /** 129 | * {@inheritdoc} 130 | */ 131 | public function update(string $table, string $recordId, array $fields, ?string $dataClass = null): ?AirtableRecord 132 | { 133 | $url = sprintf('%s/%s', $table, $recordId); 134 | 135 | return $this->createOrUpdateRecord('PATCH', $url, $fields, $dataClass); 136 | } 137 | 138 | /** 139 | * {@inheritdoc} 140 | */ 141 | public function getTablesMetadata(): ?array 142 | { 143 | $response = $this->airtableTransport->requestMeta('GET', 'tables'); 144 | 145 | return $response->toArray()['tables'] ?? null; 146 | } 147 | 148 | /** 149 | * {@inheritdoc} 150 | */ 151 | public function getTableMetadata(string $table): ?array 152 | { 153 | $tables = $this->getTablesMetadata() ?? []; 154 | foreach ($tables as $value) { 155 | if ($value['name'] === $table) { 156 | return $value; 157 | } 158 | } 159 | 160 | return null; 161 | } 162 | 163 | public function createForm(array $fields): FormInterface 164 | { 165 | $form = Forms::createFormFactoryBuilder() 166 | ->addExtension(new HttpFoundationExtension()) 167 | ->getFormFactory() 168 | ->createBuilder() 169 | ; 170 | 171 | foreach ($fields as $fieldName => $fieldType) { 172 | $form->add($fieldName, $fieldType); 173 | } 174 | 175 | return $form->getForm(); 176 | } 177 | 178 | /** 179 | * Turns an array of arrays to an array of AirtableRecord objects. 180 | * 181 | * @param array $records An array of arrays 182 | * @param string $dataClass Optionnal class name which will hold record's fields 183 | * 184 | * @return array An array of AirtableRecords objects 185 | */ 186 | private function mapRecordsToAirtableRecords(array $records, string $dataClass = null): array 187 | { 188 | return array_map( 189 | function (array $recordData) use ($dataClass): AirtableRecord { 190 | if (null !== $dataClass) { 191 | $recordData = $this->createRecordFromResponse($dataClass, $recordData); 192 | } 193 | 194 | return AirtableRecord::createFromRecord($recordData); 195 | }, 196 | $records 197 | ); 198 | } 199 | 200 | /** 201 | * Create record from response. 202 | * 203 | * @return array An AirtableRecord object 204 | */ 205 | private function createRecordFromResponse(?string $dataClass, array $recordData) 206 | { 207 | if (null !== $dataClass) { 208 | $recordData['fields'] = $this->normalizer->denormalize($recordData['fields'], $dataClass); 209 | 210 | return $recordData; 211 | } 212 | 213 | return $recordData; 214 | } 215 | 216 | /** 217 | * @throws Exception\MissingRecordDataException 218 | * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface 219 | * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface 220 | * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface 221 | * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface 222 | * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface 223 | */ 224 | private function createOrUpdateRecord(string $method, string $url, array $fields, ?string $dataClass): ?AirtableRecord 225 | { 226 | $response = $this->airtableTransport->request( 227 | $method, 228 | $url, 229 | [ 230 | 'json' => ['fields' => $fields], 231 | ] 232 | ); 233 | 234 | $recordData = $response->toArray(); 235 | 236 | if ([] === $recordData) { 237 | return null; 238 | } 239 | 240 | $recordData = $this->createRecordFromResponse($dataClass, $recordData); 241 | 242 | return AirtableRecord::createFromRecord($recordData); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/AirtableClientBundle.php: -------------------------------------------------------------------------------- 1 | extension) { 18 | $this->extension = new AirtableClientExtension(); 19 | } 20 | 21 | return $this->extension ?: null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/AirtableClientInterface.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | public function findAll(string $table, ?string $view = null, ?string $dataClass = null): array; 22 | 23 | /** 24 | * Returns a set of rows from AirTable. 25 | * 26 | * @param string $url Url to get data 27 | * @param array $response Array response from Airtable 28 | */ 29 | public function pagination(string $url, array $response): array; 30 | 31 | /** 32 | * Allows you to filter on a field in the table. 33 | * 34 | * @param string $table Table name 35 | * @param string $field Search field name 36 | * @param string $value Wanted value 37 | * @param string|null $dataClass The class name which will hold fields data 38 | * 39 | * @return array 40 | */ 41 | public function findBy(string $table, string $field, string $value, ?string $dataClass = null): array; 42 | 43 | /** 44 | * Returns one record of a table by its ID. 45 | * 46 | * @param string $table Table Name 47 | * @param string $id Id 48 | * @param string|null $dataClass The name of the class which will hold fields data 49 | */ 50 | public function find(string $table, string $id, ?string $dataClass = null): ?AirtableRecord; 51 | 52 | /** 53 | * Field allowing filtering. 54 | * 55 | * @param string $table Table name 56 | * @param mixed $field 57 | * @param string|null $dataClass The name of the class which will hold fields data 58 | */ 59 | public function findLast(string $table, $field, ?string $dataClass = null): ?AirtableRecord; 60 | 61 | /** 62 | * Create new record and return the new record of a table. 63 | * 64 | * @param string $table Table name 65 | * @param array $fields Table fields 66 | * @param string|null $dataClass The name of the class which will hold fields data 67 | */ 68 | public function add(string $table, array $fields, ?string $dataClass = null): ?AirtableRecord; 69 | 70 | /** 71 | * Update a record and return the record. 72 | * 73 | * @param string $table Table name 74 | * @param string $recordId Record Id of the element 75 | * @param array $fields Table fields 76 | * @param string|null $dataClass The name of the class which will hold fields data 77 | */ 78 | public function update(string $table, string $recordId, array $fields, ?string $dataClass = null): ?AirtableRecord; 79 | 80 | /** 81 | * Create form from an array of fields. 82 | * 83 | * @param array $fields Fields of Form 84 | */ 85 | public function createForm(array $fields): FormInterface; 86 | 87 | /** 88 | * Returns the schema of the tables in the specified base in Array. 89 | */ 90 | public function getTablesMetadata(): ?array; 91 | 92 | /** 93 | * Returns the schema of one table (by name) in the specified base in Array. 94 | */ 95 | public function getTableMetadata(string $table): ?array; 96 | } 97 | -------------------------------------------------------------------------------- /src/AirtableRecord.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private $fields; 24 | private string $id; 25 | private DateTimeInterface $createdTime; 26 | 27 | /** 28 | * @param object|array $fields 29 | */ 30 | private function __construct(string $id, $fields, DateTimeInterface $createdTime) 31 | { 32 | $this->fields = $fields; 33 | $this->id = $id; 34 | $this->createdTime = $createdTime; 35 | } 36 | 37 | /** 38 | * Returns an instance of AirtableRecord from values set in an array 39 | * Mandatory values are : 40 | * - id : the record id 41 | * - fields : the record data fields 42 | * - createdTime : the record created time (should be a valid datetime value). 43 | * 44 | * @param array $record The airtable record 45 | * 46 | * @throws MissingRecordDataException 47 | * @throws Exception 48 | */ 49 | public static function createFromRecord(array $record): self 50 | { 51 | self::assertRecordPayload($record); 52 | 53 | ['id' => $id, 'fields' => $fields, 'createdTime' => $createdTime] = $record; 54 | 55 | return new self( 56 | $id, 57 | $fields, 58 | new DateTimeImmutable($createdTime) 59 | ); 60 | } 61 | 62 | /** 63 | * @return object|array 64 | */ 65 | public function getFields() 66 | { 67 | return $this->fields; 68 | } 69 | 70 | public function getId(): string 71 | { 72 | return $this->id; 73 | } 74 | 75 | public function getCreatedTime(): DateTimeInterface 76 | { 77 | return $this->createdTime; 78 | } 79 | 80 | /** 81 | * Assert that the record payload and can be transformed to a AirtableRecord object. 82 | * 83 | * @throws MissingRecordDataException 84 | */ 85 | private static function assertRecordPayload(array $payload): void 86 | { 87 | if ([] !== $missingFields = array_diff_key(array_flip(self::MANDATORY_FIELDS), $payload)) { 88 | throw MissingRecordDataException::missingData(array_keys($missingFields)); 89 | } 90 | 91 | try { 92 | new DateTimeImmutable($payload['createdTime'] ?? ''); 93 | } catch (Exception $e) { 94 | throw MissingRecordDataException::invalidCreatedTime($payload['createdTime']); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/AirtableTransport.php: -------------------------------------------------------------------------------- 1 | id = $id; 22 | } 23 | 24 | public function request(string $method, string $url, array $options = []): ResponseInterface 25 | { 26 | $url = sprintf('%s/%s/%s', self::VERSION, $this->id, $url); 27 | 28 | return parent::request($method, $url, $options); 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function requestMeta(string $method, string $url, array $options = []): ResponseInterface 35 | { 36 | $url = sprintf('%s/meta/bases/%s/%s', self::VERSION, $this->id, $url); 37 | 38 | return parent::request($method, $url, $options); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/AirtableTransportInterface.php: -------------------------------------------------------------------------------- 1 | load('services.php'); 18 | 19 | $configuration = $this->getConfiguration($configs, $container); 20 | $config = $this->processConfiguration($configuration, $configs); 21 | 22 | $container->setParameter('yoanbernabeu_airtable_client.airtable_client.key', $config['key']); 23 | $container->setParameter('yoanbernabeu_airtable_client.airtable_client.id', $config['id']); 24 | } 25 | 26 | public function getConfiguration(array $config, ContainerBuilder $container): Configuration 27 | { 28 | return new Configuration(); 29 | } 30 | 31 | public function getAlias(): string 32 | { 33 | return 'airtable_client'; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 16 | ->children() 17 | ->scalarNode('key') 18 | ->isRequired() 19 | ->info( 20 | 'The API key. 21 | Please refer to your account settings. 22 | See https://support.airtable.com/hc/en-us/articles/219046777-How-do-I-get-my-API-key-' 23 | ) 24 | ->end() 25 | ->scalarNode('id') 26 | ->isRequired() 27 | ->info('The table ID. Please refer to your account settings.') 28 | ->end() 29 | ->end() 30 | ; 31 | 32 | return $treeBuilder; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Exception/MissingRecordDataException.php: -------------------------------------------------------------------------------- 1 | AirtableTransport::BASE_URI, 18 | 'headers' => [ 19 | 'Authorization' => 'Bearer '.param('yoanbernabeu_airtable_client.airtable_client.key'), 20 | 'Accept' => 'application/json', 21 | ], 22 | ]; 23 | 24 | $container->services()->defaults() 25 | ->public() 26 | ->autoconfigure() 27 | ->autowire() 28 | ->set('airtable_client', AirtableClient::class) 29 | ->args([service('airtable_transport')]) 30 | ->alias(AirtableClientInterface::class, 'airtable_client') 31 | 32 | ->set('airtable_transport', AirtableTransport::class) 33 | ->arg('$id', param('yoanbernabeu_airtable_client.airtable_client.id')) 34 | ->arg('$defaultOptionsByRegexp', [$scopeConfig['base_uri'] => $scopeConfig]) 35 | ->arg('$defaultRegexp', $scopeConfig['base_uri']) 36 | ->alias(AirtableTransportInterface::class, 'airtable_transport') 37 | ; 38 | }; 39 | --------------------------------------------------------------------------------