commit 1392bd3a96045302b60d845a90901a4b2234c475 Author: zino Date: Wed Jan 20 12:59:59 2021 +0100 Squashed 'seatmap-webapi/' content from commit 02d4bf7 git-subtree-dir: seatmap-webapi git-subtree-split: 02d4bf7404b8fcb788502ca45c813946b6c4f5b9 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92900b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +composer.phar +vendor/ +data.db \ No newline at end of file diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..4c81be1 --- /dev/null +++ b/.htaccess @@ -0,0 +1,5 @@ + + RewriteEngine on + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^(.*)$ api.php/$1 [QSA,L] + \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..46dfc73 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing to php-crud-api + +Pull requests are welcome. + +## Use phpfmt + +Please use "phpfmt" to ensure consistent formatting. + +## Run the tests + +Before you do a PR, you should ensure any new functionality has test cases and that all existing tests are succeeding. + +## Run the build + +Since this project is a single file application, you must ensure that classes are loaded in the correct order. +This is only important for the "extends" and "implements" relations. The 'build.php' script appends the classes in +alphabetical order (directories first). The path of the class that is extended or implemented (parent) must be above +the extending or implementing (child) class when listing the contents of the 'src' directory in this order. If you +get this order wrong you will see the build will fail with a "Class not found" error message. The solution is to +rename the child class so that it starts with a later letter in the alphabet than the parent class or that you move +the parent class to a subdirectory (directories are scanned first). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bac12f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM php:apache + +RUN docker-php-ext-install pdo pdo_mysql + +RUN apt-get update; \ + apt-get install -y libpq5 libpq-dev; \ + docker-php-ext-install pdo pdo_pgsql; \ + apt-get autoremove --purge -y libpq-dev; \ + apt-get clean ; \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* + +RUN a2enmod rewrite + +COPY api.php /var/www/html/api.php +COPY .htaccess /var/www/html/.htaccess diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc461d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Maurits van der Schee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cf2737 --- /dev/null +++ b/README.md @@ -0,0 +1,1389 @@ +# PHP-CRUD-API + +Single file PHP 7 script that adds a REST API to a MySQL/MariaDB, PostgreSQL, SQL Server or SQLite database. + +NB: This is the [TreeQL](https://treeql.org) reference implementation in PHP. + +Related projects: + + - [PHP-API-AUTH](https://github.com/mevdschee/php-api-auth): Single file PHP script that is an authentication provider for PHP-CRUD-API + - [PHP-SP-API](https://github.com/mevdschee/php-sp-api): Single file PHP script that adds a REST API to a SQL database. + - [PHP-CRUD-UI](https://github.com/mevdschee/php-crud-ui): Single file PHP script that adds a UI to a PHP-CRUD-API project. + - [VUE-CRUD-UI](https://github.com/nlware/vue-crud-ui): Single file Vue.js script that adds a UI to a PHP-CRUD-API project. + - [PHP-CRUD-ADMIN](https://github.com/mevdschee/php-crud-admin): Single file PHP script that adds a database admin interface to a PHP-CRUD-API project. + +There are also ports of this script in: + +- [Java JDBC by Ivan Kolchagov](https://github.com/kolchagov/java-crud-api) (v1) +- [Java Spring Boot + jOOQ](https://github.com/mevdschee/java-crud-api/tree/master/full) (v2: work in progress) + +There are also proof-of-concept ports of this script that only support basic REST CRUD functionality in: +[PHP](https://github.com/mevdschee/php-crud-api/blob/master/extras/core.php), +[Java](https://github.com/mevdschee/java-crud-api/blob/master/core/src/main/java/com/tqdev/CrudApiHandler.java), +[Go](https://github.com/mevdschee/go-crud-api/blob/master/api.go), +[C# .net core](https://github.com/mevdschee/core-data-api/blob/master/Program.cs), +[Node.js](https://github.com/mevdschee/js-crud-api/blob/master/app.js) and +[Python](https://github.com/mevdschee/py-crud-api/blob/master/api.py). + +## Requirements + + - PHP 7.0 or higher with PDO drivers enabled for one of these database systems: + - MySQL 5.6 / MariaDB 10.0 or higher for spatial features in MySQL + - PostgreSQL 9.1 or higher with PostGIS 2.0 or higher for spatial features + - SQL Server 2012 or higher (2017 for Linux support) + - SQLite 3.16 or higher (spatial features NOT supported) + +## Installation + +This is a single file application! Upload "`api.php`" somewhere and enjoy! + +For local development you may run PHP's built-in web server: + + php -S localhost:8080 + +Test the script by opening the following URL: + + http://localhost:8080/api.php/records/posts/1 + +Don't forget to modify the configuration at the bottom of the file. + +Alternatively you can integrate this project into the web framework of your choice, see: + +- [Automatic REST API for Laravel](https://tqdev.com/2019-automatic-rest-api-laravel) +- [Automatic REST API for Symfony 4](https://tqdev.com/2019-automatic-rest-api-symfony) +- [Automatic REST API for SlimPHP 4](https://tqdev.com/2019-automatic-api-slimphp-4) + +In these integrations [Composer](https://getcomposer.org/) is used to load this project as a dependency. + +For people that don't use composer, the file "`api.include.php`" is provided. This file contains everything +from "`api.php`" except the configuration from "`src/index.php`" and can be used by PHP's "include". + +## Configuration + +Edit the following lines in the bottom of the file "`api.php`": + + $config = new Config([ + 'username' => 'xxx', + 'password' => 'xxx', + 'database' => 'xxx', + ]); + +These are all the configuration options and their default value between brackets: + +- "driver": `mysql`, `pgsql`, `sqlsrv` or `sqlite` (`mysql`) +- "address": Hostname (or filename) of the database server (`localhost`) +- "port": TCP port of the database server (defaults to driver default) +- "username": Username of the user connecting to the database (no default) +- "password": Password of the user connecting to the database (no default) +- "database": Database the connecting is made to (no default) +- "tables": Comma separated list of tables to publish (defaults to 'all') +- "middlewares": List of middlewares to load (`cors`) +- "controllers": List of controllers to load (`records,geojson,openapi`) +- "openApiBase": OpenAPI info (`{"info":{"title":"PHP-CRUD-API","version":"1.0.0"}}`) +- "cacheType": `TempFile`, `Redis`, `Memcache`, `Memcached` or `NoCache` (`TempFile`) +- "cachePath": Path/address of the cache (defaults to system's temp directory) +- "cacheTime": Number of seconds the cache is valid (`10`) +- "debug": Show errors in the "X-Exception" headers (`false`) +- "basePath": URI base path of the API (determined using PATH_INFO by default) + +All configuration options are also available as environment variables. Write the config option with capitals, a "PHP_CRUD_API_" prefix and underscores for word breakes, so for instance: + +- PHP_CRUD_API_DRIVER=mysql +- PHP_CRUD_API_ADDRESS=localhost +- PHP_CRUD_API_PORT=3306 +- PHP_CRUD_API_DATABASE=php-crud-api +- PHP_CRUD_API_USERNAME=php-crud-api +- PHP_CRUD_API_PASSWORD=php-crud-api +- PHP_CRUD_API_DEBUG=1 + +The environment variables take precedence over the PHP configuration. + +## Limitations + +These limitation and constrains apply: + + - Primary keys should either be auto-increment (from 1 to 2^53) or UUID + - Composite primary and composite foreign keys are not supported + - Complex writes (transactions) are not supported + - Complex queries calling functions (like "concat" or "sum") are not supported + - Database must support and define foreign key constraints + - SQLite cannot have bigint typed auto incrementing primary keys + - SQLite does not support altering table columns (structure) + +## Features + +The following features are supported: + + - Composer install or single PHP file, easy to deploy. + - Very little code, easy to adapt and maintain + - Supports POST variables as input (x-www-form-urlencoded) + - Supports a JSON object as input + - Supports a JSON array as input (batch insert) + - Sanitize and validate input using type rules and callbacks + - Permission system for databases, tables, columns and records + - Multi-tenant single and multi database layouts are supported + - Multi-domain CORS support for cross-domain requests + - Support for reading joined results from multiple tables + - Search support on multiple criteria + - Pagination, sorting, top N list and column selection + - Relation detection with nested results (belongsTo, hasMany and HABTM) + - Atomic increment support via PATCH (for counters) + - Binary fields supported with base64 encoding + - Spatial/GIS fields and filters supported with WKT and GeoJSON + - Generate API documentation using OpenAPI tools + - Authentication via JWT token or username/password + - Database connection parameters may depend on authentication + - Support for reading database structure in JSON + - Support for modifying database structure using REST endpoint + - Security enhancing middleware is included + - Standard compliant: PSR-4, PSR-7, PSR-12, PSR-15 and PSR-17 + +## Compilation + +You can install all dependencies of this project using the following command: + + php install.php + +You can compile all files into a single "`api.php`" file using: + + php build.php + +NB: The install script will patch the dependencies in the vendor directory for PHP 7.0 compatibility. + +### Development + +You can access the non-compiled code at the URL: + + http://localhost:8080/src/records/posts/1 + +The non-compiled code resides in the "`src`" and "`vendor`" directories. The "`vendor`" directory contains the dependencies. + +### Updating dependencies + +You can update all dependencies of this project using the following command: + + php update.php + +This script will install and run [Composer](https://getcomposer.org/) to update the dependencies. + +NB: The update script will patch the dependencies in the vendor directory for PHP 7.0 compatibility. + +## TreeQL, a pragmatic GraphQL + +[TreeQL](https://treeql.org) allows you to create a "tree" of JSON objects based on your SQL database structure (relations) and your query. + +It is loosely based on the REST standard and also inspired by json:api. + +### CRUD + List + +The example posts table has only a a few fields: + + posts + ======= + id + title + content + created + +The CRUD + List operations below act on this table. + +#### Create + +If you want to create a record the request can be written in URL format as: + + POST /records/posts + +You have to send a body containing: + + { + "title": "Black is the new red", + "content": "This is the second post.", + "created": "2018-03-06T21:34:01Z" + } + +And it will return the value of the primary key of the newly created record: + + 2 + +#### Read + +To read a record from this table the request can be written in URL format as: + + GET /records/posts/1 + +Where "1" is the value of the primary key of the record that you want to read. It will return: + + { + "id": 1 + "title": "Hello world!", + "content": "Welcome to the first post.", + "created": "2018-03-05T20:12:56Z" + } + +On read operations you may apply joins. + +#### Update + +To update a record in this table the request can be written in URL format as: + + PUT /records/posts/1 + +Where "1" is the value of the primary key of the record that you want to update. Send as a body: + + { + "title": "Adjusted title!" + } + +This adjusts the title of the post. And the return value is the number of rows that are set: + + 1 + +#### Delete + +If you want to delete a record from this table the request can be written in URL format as: + + DELETE /records/posts/1 + +And it will return the number of deleted rows: + + 1 + +#### List + +To list records from this table the request can be written in URL format as: + + GET /records/posts + +It will return: + + { + "records":[ + { + "id": 1, + "title": "Hello world!", + "content": "Welcome to the first post.", + "created": "2018-03-05T20:12:56Z" + } + ] + } + +On list operations you may apply filters and joins. + +### Filters + +Filters provide search functionality, on list calls, using the "filter" parameter. You need to specify the column +name, a comma, the match type, another commma and the value you want to filter on. These are supported match types: + + - "cs": contain string (string contains value) + - "sw": start with (string starts with value) + - "ew": end with (string end with value) + - "eq": equal (string or number matches exactly) + - "lt": lower than (number is lower than value) + - "le": lower or equal (number is lower than or equal to value) + - "ge": greater or equal (number is higher than or equal to value) + - "gt": greater than (number is higher than value) + - "bt": between (number is between two comma separated values) + - "in": in (number or string is in comma separated list of values) + - "is": is null (field contains "NULL" value) + +You can negate all filters by prepending a "n" character, so that "eq" becomes "neq". +Examples of filter usage are: + + GET /records/categories?filter=name,eq,Internet + GET /records/categories?filter=name,sw,Inter + GET /records/categories?filter=id,le,1 + GET /records/categories?filter=id,ngt,1 + GET /records/categories?filter=id,bt,0,1 + GET /records/categories?filter=id,in,0,1 + +Output: + + { + "records":[ + { + "id": 1 + "name": "Internet" + } + ] + } + +In the next section we dive deeper into how you can apply multiple filters on a single list call. + +### Multiple filters + +Filters can be a by applied by repeating the "filter" parameter in the URL. For example the following URL: + + GET /records/categories?filter=id,gt,1&filter=id,lt,3 + +will request all categories "where id > 1 and id < 3". If you wanted "where id = 2 or id = 4" you should write: + + GET /records/categories?filter1=id,eq,2&filter2=id,eq,4 + +As you see we added a number to the "filter" parameter to indicate that "OR" instead of "AND" should be applied. +Note that you can also repeat "filter1" and create an "AND" within an "OR". Since you can also go one level deeper +by adding a letter (a-f) you can create almost any reasonably complex condition tree. + +NB: You can only filter on the requested table (not on it's included tables) and filters are only applied on list calls. + +### Column selection + +By default all columns are selected. With the "include" parameter you can select specific columns. +You may use a dot to separate the table name from the column name. Multiple columns should be comma separated. +An asterisk ("*") may be used as a wildcard to indicate "all columns". Similar to "include" you may use the "exclude" parameter to remove certain columns: + +``` +GET /records/categories/1?include=name +GET /records/categories/1?include=categories.name +GET /records/categories/1?exclude=categories.id +``` + +Output: + +``` + { + "name": "Internet" + } +``` + +NB: Columns that are used to include related entities are automatically added and cannot be left out of the output. + +### Ordering + +With the "order" parameter you can sort. By default the sort is in ascending order, but by specifying "desc" this can be reversed: + +``` +GET /records/categories?order=name,desc +GET /records/categories?order=id,desc&order=name +``` + +Output: + +``` + { + "records":[ + { + "id": 3 + "name": "Web development" + }, + { + "id": 1 + "name": "Internet" + } + ] + } +``` + +NB: You may sort on multiple fields by using multiple "order" parameters. You can not order on "joined" columns. + +### Limit size + +The "size" parameter limits the number of returned records. This can be used for top N lists together with the "order" parameter (use descending order). + +``` +GET /records/categories?order=id,desc&size=1 +``` + +Output: + +``` + { + "records":[ + { + "id": 3 + "name": "Web development" + } + ] + } +``` + +NB: If you also want to know to the total number of records you may want to use the "page" parameter. + +### Pagination + +The "page" parameter holds the requested page. The default page size is 20, but can be adjusted (e.g. to 50). + +``` +GET /records/categories?order=id&page=1 +GET /records/categories?order=id&page=1,50 +``` + +Output: + +``` + { + "records":[ + { + "id": 1 + "name": "Internet" + }, + { + "id": 3 + "name": "Web development" + } + ], + "results": 2 + } +``` + +NB: Since pages that are not ordered cannot be paginated, pages will be ordered by primary key. + +### Joins + +Let's say that you have a posts table that has comments (made by users) and the posts can have tags. + + posts comments users post_tags tags + ======= ======== ======= ========= ======= + id id id id id + title post_id username post_id name + content user_id phone tag_id + created message + +When you want to list posts with their comments users and tags you can ask for two "tree" paths: + + posts -> comments -> users + posts -> post_tags -> tags + +These paths have the same root and this request can be written in URL format as: + + GET /records/posts?join=comments,users&join=tags + +Here you are allowed to leave out the intermediate table that binds posts to tags. In this example +you see all three table relation types (hasMany, belongsTo and hasAndBelongsToMany) in effect: + +- "post" has many "comments" +- "comment" belongs to "user" +- "post" has and belongs to many "tags" + +This may lead to the following JSON data: + + { + "records":[ + { + "id": 1, + "title": "Hello world!", + "content": "Welcome to the first post.", + "created": "2018-03-05T20:12:56Z", + "comments": [ + { + id: 1, + post_id: 1, + user_id: { + id: 1, + username: "mevdschee", + phone: null, + }, + message: "Hi!" + }, + { + id: 2, + post_id: 1, + user_id: { + id: 1, + username: "mevdschee", + phone: null, + }, + message: "Hi again!" + } + ], + "tags": [] + }, + { + "id": 2, + "title": "Black is the new red", + "content": "This is the second post.", + "created": "2018-03-06T21:34:01Z", + "comments": [], + "tags": [ + { + id: 1, + message: "Funny" + }, + { + id: 2, + message: "Informational" + } + ] + } + ] + } + +You see that the "belongsTo" relationships are detected and the foreign key value is replaced by the referenced object. +In case of "hasMany" and "hasAndBelongsToMany" the table name is used a new property on the object. + +### Batch operations + +When you want to create, read, update or delete you may specify multiple primary key values in the URL. +You also need to send an array instead of an object in the request body for create and update. + +To read a record from this table the request can be written in URL format as: + + GET /records/posts/1,2 + +The result may be: + + [ + { + "id": 1, + "title": "Hello world!", + "content": "Welcome to the first post.", + "created": "2018-03-05T20:12:56Z" + }, + { + "id": 2, + "title": "Black is the new red", + "content": "This is the second post.", + "created": "2018-03-06T21:34:01Z" + } + ] + +Similarly when you want to do a batch update the request in URL format is written as: + + PUT /records/posts/1,2 + +Where "1" and "2" are the values of the primary keys of the records that you want to update. The body should +contain the same number of objects as there are primary keys in the URL: + + [ + { + "title": "Adjusted title for ID 1" + }, + { + "title": "Adjusted title for ID 2" + } + ] + +This adjusts the titles of the posts. And the return values are the number of rows that are set: + + 1,1 + +Which means that there were two update operations and each of them had set one row. Batch operations use database +transactions, so they either all succeed or all fail (successful ones get roled back). + +### Spatial support + +For spatial support there is an extra set of filters that can be applied on geometry columns and that starting with an "s": + + - "sco": spatial contains (geometry contains another) + - "scr": spatial crosses (geometry crosses another) + - "sdi": spatial disjoint (geometry is disjoint from another) + - "seq": spatial equal (geometry is equal to another) + - "sin": spatial intersects (geometry intersects another) + - "sov": spatial overlaps (geometry overlaps another) + - "sto": spatial touches (geometry touches another) + - "swi": spatial within (geometry is within another) + - "sic": spatial is closed (geometry is closed and simple) + - "sis": spatial is simple (geometry is simple) + - "siv": spatial is valid (geometry is valid) + +These filters are based on OGC standards and so is the WKT specification in which the geometry columns are represented. + +#### GeoJSON + +The GeoJSON support is a read-only view on the tables and records in GeoJSON format. These requests are supported: + + method path - operation - description + ---------------------------------------------------------------------------------------- + GET /geojson/{table} - list - lists records as a GeoJSON FeatureCollection + GET /geojson/{table}/{id} - read - reads a record by primary key as a GeoJSON Feature + +The "`/geojson`" endpoint uses the "`/records`" endpoint internally and inherits all functionality, such as joins and filters. +It also supports a "geometry" parameter to indicate the name of the geometry column in case the table has more than one. +For map views it supports the "bbox" parameter in which you can specify upper-left and lower-right coordinates (comma separated). +The following Geometry types are supported by the GeoJSON implementation: + + - Point + - MultiPoint + - LineString + - MultiLineString + - Polygon + - MultiPolygon + +The GeoJSON functionality is enabled by default, but can be disabled using the "controllers" configuration. + +## Middleware + +You can enable the following middleware using the "middlewares" config parameter: + +- "firewall": Limit access to specific IP addresses +- "sslRedirect": Force connection over HTTPS instead of HTTP +- "cors": Support for CORS requests (enabled by default) +- "xsrf": Block XSRF attacks using the 'Double Submit Cookie' method +- "ajaxOnly": Restrict non-AJAX requests to prevent XSRF attacks +- "dbAuth": Support for "Database Authentication" +- "jwtAuth": Support for "JWT Authentication" +- "basicAuth": Support for "Basic Authentication" +- "reconnect": Reconnect to the database with different parameters +- "authorization": Restrict access to certain tables or columns +- "validation": Return input validation errors for custom rules and default type rules +- "ipAddress": Fill a protected field with the IP address on create +- "sanitation": Apply input sanitation on create and update +- "multiTenancy": Restricts tenants access in a multi-tenant scenario +- "pageLimits": Restricts list operations to prevent database scraping +- "joinLimits": Restricts join parameters to prevent database scraping +- "customization": Provides handlers for request and response customization +- "xml": Translates all input and output from JSON to XML + +The "middlewares" config parameter is a comma separated list of enabled middlewares. +You can tune the middleware behavior using middleware specific configuration parameters: + +- "firewall.reverseProxy": Set to "true" when a reverse proxy is used ("") +- "firewall.allowedIpAddresses": List of IP addresses that are allowed to connect ("") +- "cors.allowedOrigins": The origins allowed in the CORS headers ("*") +- "cors.allowHeaders": The headers allowed in the CORS request ("Content-Type, X-XSRF-TOKEN, X-Authorization") +- "cors.allowMethods": The methods allowed in the CORS request ("OPTIONS, GET, PUT, POST, DELETE, PATCH") +- "cors.allowCredentials": To allow credentials in the CORS request ("true") +- "cors.exposeHeaders": Whitelist headers that browsers are allowed to access ("") +- "cors.maxAge": The time that the CORS grant is valid in seconds ("1728000") +- "xsrf.excludeMethods": The methods that do not require XSRF protection ("OPTIONS,GET") +- "xsrf.cookieName": The name of the XSRF protection cookie ("XSRF-TOKEN") +- "xsrf.headerName": The name of the XSRF protection header ("X-XSRF-TOKEN") +- "ajaxOnly.excludeMethods": The methods that do not require AJAX ("OPTIONS,GET") +- "ajaxOnly.headerName": The name of the required header ("X-Requested-With") +- "ajaxOnly.headerValue": The value of the required header ("XMLHttpRequest") +- "dbAuth.mode": Set to "optional" if you want to allow anonymous access ("required") +- "dbAuth.usersTable": The table that is used to store the users in ("users") +- "dbAuth.usernameColumn": The users table column that holds usernames ("username") +- "dbAuth.passwordColumn": The users table column that holds passwords ("password") +- "dbAuth.returnedColumns": The columns returned on successful login, empty means 'all' ("") +- "dbAuth.registerUser": JSON user data (or "1") in case you want the /register endpoint enabled ("") +- "dbAuth.passwordLength": Minimum length that the password must have ("12") +- "dbAuth.sessionName": The name of the PHP session that is started ("") +- "jwtAuth.mode": Set to "optional" if you want to allow anonymous access ("required") +- "jwtAuth.header": Name of the header containing the JWT token ("X-Authorization") +- "jwtAuth.leeway": The acceptable number of seconds of clock skew ("5") +- "jwtAuth.ttl": The number of seconds the token is valid ("30") +- "jwtAuth.secrets": The shared secret(s) used to sign the JWT token with ("") +- "jwtAuth.algorithms": The algorithms that are allowed, empty means 'all' ("") +- "jwtAuth.audiences": The audiences that are allowed, empty means 'all' ("") +- "jwtAuth.issuers": The issuers that are allowed, empty means 'all' ("") +- "jwtAuth.sessionName": The name of the PHP session that is started ("") +- "basicAuth.mode": Set to "optional" if you want to allow anonymous access ("required") +- "basicAuth.realm": Text to prompt when showing login ("Username and password required") +- "basicAuth.passwordFile": The file to read for username/password combinations (".htpasswd") +- "basicAuth.sessionName": The name of the PHP session that is started ("") +- "reconnect.driverHandler": Handler to implement retrieval of the database driver ("") +- "reconnect.addressHandler": Handler to implement retrieval of the database address ("") +- "reconnect.portHandler": Handler to implement retrieval of the database port ("") +- "reconnect.databaseHandler": Handler to implement retrieval of the database name ("") +- "reconnect.tablesHandler": Handler to implement retrieval of the table names ("") +- "reconnect.usernameHandler": Handler to implement retrieval of the database username ("") +- "reconnect.passwordHandler": Handler to implement retrieval of the database password ("") +- "authorization.tableHandler": Handler to implement table authorization rules ("") +- "authorization.columnHandler": Handler to implement column authorization rules ("") +- "authorization.pathHandler": Handler to implement path authorization rules ("") +- "authorization.recordHandler": Handler to implement record authorization filter rules ("") +- "validation.handler": Handler to implement validation rules for input values ("") +- "validation.types": Types to enable type validation for, empty means 'none' ("all") +- "validation.tables": Tables to enable type validation for, empty means 'none' ("all") +- "ipAddress.tables": Tables to search for columns to override with IP address ("") +- "ipAddress.columns": Columns to protect and override with the IP address on create ("") +- "sanitation.handler": Handler to implement sanitation rules for input values ("") +- "sanitation.types": Types to enable type sanitation for, empty means 'none' ("all") +- "sanitation.tables": Tables to enable type sanitation for, empty means 'none' ("all") +- "multiTenancy.handler": Handler to implement simple multi-tenancy rules ("") +- "pageLimits.pages": The maximum page number that a list operation allows ("100") +- "pageLimits.records": The maximum number of records returned by a list operation ("1000") +- "joinLimits.depth": The maximum depth (length) that is allowed in a join path ("3") +- "joinLimits.tables": The maximum number of tables that you are allowed to join ("10") +- "joinLimits.records": The maximum number of records returned for a joined entity ("1000") +- "customization.beforeHandler": Handler to implement request customization ("") +- "customization.afterHandler": Handler to implement response customization ("") +- "xml.types": JSON types that should be added to the XML type attribute ("null,array") + +If you don't specify these parameters in the configuration, then the default values (between brackets) are used. + +In the sections below you find more information on the built-in middleware. + +### Authentication + +Currently there are three types of authentication supported. They all store the authenticated user in the `$_SESSION` super global. +This variable can be used in the authorization handlers to decide wether or not sombeody should have read or write access to certain tables, columns or records. +The following overview shows the kinds of authentication middleware that you can enable. + +| Name | Middleware | Authenticated via | Users are stored in | Session variable | +| -------- | ---------- | ---------------------- | ------------------- | ----------------------- | +| Database | dbAuth | '/login' endpoint | database table | `$_SESSION['user']` | +| Basic | basicAuth | 'Authorization' header | '.htpasswd' file | `$_SESSION['username']` | +| JWT | jwtAuth | 'Authorization' header | identity provider | `$_SESSION['claims']` | + +Below you find more information on each of the authentication types. + +#### Database authentication + +The database authentication middleware defines three new routes: + + method path - parameters - description + --------------------------------------------------------------------------------------------------- + GET /me - - returns the user that is currently logged in + POST /register - username, password - adds a user with given username and password + POST /login - username, password - logs a user in by username and password + POST /password - username, password, newPassword - updates the password of the logged in user + POST /logout - - logs out the currently logged in user + +A user can be logged in by sending it's username and password to the login endpoint (in JSON format). +The authenticated user (with all it's properties) will be stored in the `$_SESSION['user']` variable. +The user can be logged out by sending a POST request with an empty body to the logout endpoint. +The passwords are stored as hashes in the password column in the users table. You can register a new user +using the register endpoint, but this functionality must be turned on using the "dbAuth.regsiterUser" +configuration parameter. + +It is IMPORTANT to restrict access to the users table using the 'authorization' middleware, otherwise all +users can freely add, modify or delete any account! The minimal configuration is shown below: + + 'middlewares' => 'dbAuth,authorization', + 'authorization.tableHandler' => function ($operation, $tableName) { + return $tableName != 'users'; + }, + +Note that this middleware uses session cookies and stores the logged in state on the server. + +#### Basic authentication + +The Basic type supports a file (by default '.htpasswd') that holds the users and their (hashed) passwords separated by a colon (':'). +When the passwords are entered in plain text they fill be automatically hashed. +The authenticated username will be stored in the `$_SESSION['username']` variable. +You need to send an "Authorization" header containing a base64 url encoded and colon separated username and password after the word "Basic". + + Authorization: Basic dXNlcm5hbWUxOnBhc3N3b3JkMQ + +This example sends the string "username1:password1". + +#### JWT authentication + +The JWT type requires another (SSO/Identity) server to sign a token that contains claims. +Both servers share a secret so that they can either sign or verify that the signature is valid. +Claims are stored in the `$_SESSION['claims']` variable. You need to send an "X-Authorization" +header containing a base64 url encoded and dot separated token header, body and signature after +the word "Bearer" ([read more about JWT here](https://jwt.io/)). The standard says you need to +use the "Authorization" header, but this is problematic in Apache and PHP. + + X-Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6IjE1MzgyMDc2MDUiLCJleHAiOjE1MzgyMDc2MzV9.Z5px_GT15TRKhJCTHhDt5Z6K6LRDSFnLj8U5ok9l7gw + +This example sends the signed claims: + + { + "sub": "1234567890", + "name": "John Doe", + "admin": true, + "iat": "1538207605", + "exp": 1538207635 + } + +NB: The JWT implementation only supports the RSA and HMAC based algorithms. + +##### Configure and test JWT authentication with Auth0 + +First you need to create an account on [Auth0](https://auth0.com/auth/login). +Once logged in, you have to create an application (its type does not matter). Collect the `Domain` +and `Client ID` and keep them for a later use. Then, create an API: give it a name and fill the +`identifier` field with your API endpoint's URL. + +Then you have to configure the `jwtAuth.secrets` configuration in your `api.php` file. +Don't fill it with the `secret` you will find in your Auth0 application settings but with **a +public certificate**. To find it, go to the settings of your application, then in "Extra settings". +You will now find a "Certificates" tab where you will find your Public Key in the Signing +Certificate field. + +To test your integration, you can copy the [auth0/vanilla.html](examples/clients/auth0/vanilla.html) +file. Be sure to fill these three variables: + + - `authUrl` with your Auth0 domain + - `clientId` with your Client ID + - `audience` with the API URL you created in Auth0 + +⚠️ If you don't fill the audience parameter, it will not work because you won't get a valid JWT. + +You can also change the `url` variable, used to test the API with authentication. + +[More info](https://auth0.com/docs/api-auth/tutorials/verify-access-token) + +##### Configure and test JWT authentication with Firebase + +First you need to create a Firebase project on the [Firebase console](https://console.firebase.google.com/). +Add a web application to this project and grab the code snippet for later use. + +Then you have to configure the `jwtAuth.secrets` configuration in your `api.php` file. +This can be done as follows: + +a. Log a user in to your Firebase-based app, get an authentication token for that user +b. Go to [https://jwt.io/](https://jwt.io/) and paste the token in the decoding field +c. Read the decoded header information from the token, it will give you the correct `kid` +d. Grab the public key via this [URL](https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com), which corresponds to your `kid` from previous step +e. Now, just fill `jwtAuth.secrets` with your public key in the `api.php` + +Here is an example of what it should look like in the configuration: + +``` +..., +'middlewares' => 'cors, jwtAuth, authorization', + 'jwtAuth.secrets' => "ce5ced6e40dcd1eff407048867b1ed1e706686a0:-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIIExun9bJSK1wwDQYJKoZIhvcNAQEFBQAwMTEvMC0GA1UE\nAxMmc2VjdXJldG9rZW4uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wHhcNMTkx\nMjIyMjEyMTA3WhcNMjAwMTA4MDkzNjA3WjAxMS8wLQYDVQQDEyZzZWN1cmV0b2tl\nbi5zeXN0ZW0uZ3NlcnZpY2VhY2NvdW50LmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAKsvVDUwXeYQtySNvyI1/tZAk0sj7Zx4/1+YLUomwlK6vmEd\nyl2IXOYOj3VR7FBA24A9//nnrp+mV8YOYEOdaWX7PQo0PIPFPqdA0r7CqBUWHPfQ\n1WVHVRQY3G0c7upM97UfMes9xOrMqyvecMRk1e5S6eT12Zh2og7yiVs8gP83M1EB\nGqseUaltaadjyT35w5B0Ny0/7NdLYiv2G6Z0S821SxvSo1/wfmilnBBKYYluP0PA\n9NPznWFP6uXnX7gKxyJT9//cYVxTO6+b1TT13Yvrpm1a4EuCOhLrZH6ErHQTccAM\nhAx8mdNtbROsp0dlPKrSfqO82uFz45RXZYmSeP0CAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwDQYJ\nKoZIhvcNAQEFBQADggEBACNsJ5m00gdTvD6j6ahURsGrNZ0VJ0YREVQ5U2Jtubr8\nn2fuhMxkB8147ISzfi6wZR+yNwPGjlr8JkAHAC0i+Nam9SqRyfZLqsm+tHdgFT8h\npa+R/FoGrrLzxJNRiv0Trip8hZjgz3PClz6KxBQzqL+rfGV2MbwTXuBoEvLU1mYA\no3/UboJT7cNGjZ8nHXeoKMsec1/H55lUdconbTm5iMU1sTDf+3StGYzTwC+H6yc2\nY3zIq3/cQUCrETkALrqzyCnLjRrLYZu36ITOaKUbtmZhwrP99i2f+H4Ab2i8jeMu\nk61HD29mROYjl95Mko2BxL+76To7+pmn73U9auT+xfA=\n-----END CERTIFICATE-----\n", + 'cors.allowedOrigins' => '*', + 'cors.allowHeaders' => 'X-Authorization' +``` + +Notes: + - The `kid:key` pair is formatted as a string + - Do not include spaces before or after the ':' + - Use double quotation marks (") around the string text + - The string must contain the linefeeds (\n) + +To test your integration, you can copy the [firebase/vanilla.html](examples/clients/firebase/vanilla.html) +file and the [firebase/vanilla-success.html](examples/clients/firebase/vanilla-success.html) file, +used as a "success" page and to display the API result. + +Replace, in both files, the Firebase configuration (`firebaseConfig` object). + +You can also change the `url` variable, used to test the API with authentication. + +[More info](https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library) + +### Authorizing operations + +The Authorization model acts on "operations". The most important ones are listed here: + + method path - operation - description + ---------------------------------------------------------------------------------------- + GET /records/{table} - list - lists records + POST /records/{table} - create - creates records + GET /records/{table}/{id} - read - reads a record by primary key + PUT /records/{table}/{id} - update - updates columns of a record by primary key + DELETE /records/{table}/{id} - delete - deletes a record by primary key + PATCH /records/{table}/{id} - increment - increments columns of a record by primary key + +The "`/openapi`" endpoint will only show what is allowed in your session. It also has a special +"document" operation to allow you to hide tables and columns from the documentation. + +For endpoints that start with "`/columns`" there are the operations "reflect" and "remodel". +These operations can display or change the definition of the database, table or column. +This functionality is disabled by default and for good reason (be careful!). +Add the "columns" controller in the configuration to enable this functionality. + +### Authorizing tables, columns and records + +By default all tables, columns and paths are accessible. If you want to restrict access to some tables you may add the 'authorization' middleware +and define a 'authorization.tableHandler' function that returns 'false' for these tables. + + 'authorization.tableHandler' => function ($operation, $tableName) { + return $tableName != 'license_keys'; + }, + +The above example will restrict access to the table 'license_keys' for all operations. + + 'authorization.columnHandler' => function ($operation, $tableName, $columnName) { + return !($tableName == 'users' && $columnName == 'password'); + }, + +The above example will restrict access to the 'password' field of the 'users' table for all operations. + + 'authorization.recordHandler' => function ($operation, $tableName) { + return ($tableName == 'users') ? 'filter=username,neq,admin' : ''; + }, + +The above example will disallow access to user records where the username is 'admin'. +This construct adds a filter to every executed query. + + 'authorization.pathHandler' => function ($path) { + return $path === 'openapi' ? false : true; + }, + +The above example will disabled the `/openapi` route. + +NB: You need to handle the creation of invalid records with a validation (or sanitation) handler. + +### SQL GRANT authorization + +You can alternatively use database permissons (SQL GRANT statements) to define the authorization model. In this case you +should not use the "authorization" middleware, but you do need to use the "reconnect" middleware. The handlers of the +"reconnect" middleware allow you to specify the correct username and password, like this: + + 'reconnect.usernameHandler' => function () { + return 'mevdschee'; + }, + 'reconnect.passwordHandler' => function () { + return 'secret123'; + }, + +This will make the API connect to the database specifying "mevdschee" as the username and "secret123" as the password. +The OpenAPI specification is less specific on allowed and disallowed operations when you are using database permissions, +as the permissions are not read in the reflection step. + +NB: You may want to retrieve the username and password from the session (the "$_SESSION" variable). + +### Sanitizing input + +By default all input is accepted and sent to the database. If you want to strip (certain) HTML tags before storing you may add +the 'sanitation' middleware and define a 'sanitation.handler' function that returns the adjusted value. + + 'sanitation.handler' => function ($operation, $tableName, $column, $value) { + return is_string($value) ? strip_tags($value) : $value; + }, + +The above example will strip all HTML tags from strings in the input. + +### Type sanitation + +If you enable the 'sanitation' middleware, then you (automatically) also enable type sanitation. When this is enabled you may: + +- send leading and trailing whitespace in a non-character field (it will be ignored). +- send a float to an integer or bigint field (it will be rounded). +- send a base64url encoded string (it will be converted to regular base64 encoding). +- send a time/date/timestamp in any [strtotime accepted format](https://www.php.net/manual/en/datetime.formats.php) (it will be converted). + +You may use the config settings "`sanitation.types`" and "`sanitation.tables`"' to define for which types and +in which tables you want to apply type sanitation (defaults to 'all'). Example: + + 'sanitation.types' => 'date,timestamp', + 'sanitation.tables' => 'posts,comments', + +Here we enable the type sanitation for date and timestamp fields in the posts and comments tables. + +### Validating input + +By default all input is accepted and sent to the database. If you want to validate the input in a custom way, +you may add the 'validation' middleware and define a 'validation.handler' function that returns a boolean +indicating whether or not the value is valid. + + 'validation.handler' => function ($operation, $tableName, $column, $value, $context) { + return ($column['name'] == 'post_id' && !is_numeric($value)) ? 'must be numeric' : true; + }, + +When you edit a comment with id 4 using: + + PUT /records/comments/4 + +And you send as a body: + + {"post_id":"two"} + +Then the server will return a '422' HTTP status code and nice error message: + + { + "code": 1013, + "message": "Input validation failed for 'comments'", + "details": { + "post_id":"must be numeric" + } + } + +You can parse this output to make form fields show up with a red border and their appropriate error message. + +### Type validations + +If you enable the 'validation' middleware, then you (automatically) also enable type validation. +This includes the following error messages: + +| error message | reason | applies to types | +| ------------------- | --------------------------- | ------------------------------------------- | +| cannot be null | unexpected null value | (any non-nullable column) | +| illegal whitespace | leading/trailing whitespace | integer bigint decimal float double boolean | +| invalid integer | illegal characters | integer bigint | +| string too long | too many characters | varchar varbinary | +| invalid decimal | illegal characters | decimal | +| decimal too large | too many digits before dot | decimal | +| decimal too precise | too many digits after dot | decimal | +| invalid float | illegal characters | float double | +| invalid boolean | use 1, 0, true or false | boolean | +| invalid date | use yyyy-mm-dd | date | +| invalid time | use hh:mm:ss | time | +| invalid timestamp | use yyyy-mm-dd hh:mm:ss | timestamp | +| invalid base64 | illegal characters | varbinary, blob | + +You may use the config settings "`validation.types`" and "`validation.tables`"' to define for which types and +in which tables you want to apply type validation (defaults to 'all'). Example: + + 'validation.types' => 'date,timestamp', + 'validation.tables' => 'posts,comments', + +Here we enable the type validation for date and timestamp fields in the posts and comments tables. + +NB: Types that are enabled will be checked for null values when the column is non-nullable. + +### Multi-tenancy support + +Two forms of multi-tenancy are supported: + + - Single database, where every table has a tenant column (using the "multiTenancy" middleware). + - Multi database, where every tenant has it's own database (using the "reconnect" middleware). + +Below is an explanation of the corresponding middlewares. + +#### Multi-tenancy middleware + +You may use the "multiTenancy" middleware when you have a single multi-tenant database. +If your tenants are identified by the "customer_id" column, then you can use the following handler: + + 'multiTenancy.handler' => function ($operation, $tableName) { + return ['customer_id' => 12]; + }, + +This construct adds a filter requiring "customer_id" to be "12" to every operation (except for "create"). +It also sets the column "customer_id" on "create" to "12" and removes the column from any other write operation. + +NB: You may want to retrieve the customer id from the session (the "$_SESSION" variable). + +#### Reconnect middleware + +You may use the "reconnect" middleware when you have a separate database for each tenant. +If the tenant has it's own database named "customer_12", then you can use the following handler: + + 'reconnect.databaseHandler' => function () { + return 'customer_12'; + }, + +This will make the API reconnect to the database specifying "customer_12" as the database name. If you don't want +to use the same credentials, then you should also implement the "usernameHandler" and "passwordHandler". + +NB: You may want to retrieve the database name from the session (the "$_SESSION" variable). + +### Prevent database scraping + +You may use the "joinLimits" and "pageLimits" middleware to prevent database scraping. +The "joinLimits" middleware limits the table depth, number of tables and number of records returned in a join operation. +If you want to allow 5 direct direct joins with a maximum of 25 records each, you can specify: + + 'joinLimits.depth' => 1, + 'joinLimits.tables' => 5, + 'joinLimits.records' => 25, + +The "pageLimits" middleware limits the page number and the number records returned from a list operation. +If you want to allow no more than 10 pages with a maximum of 25 records each, you can specify: + + 'pageLimits.pages' => 10, + 'pageLimits.records' => 25, + +NB: The maximum number of records is also applied when there is no page number specified in the request. + +### Customization handlers + +You may use the "customization" middleware to modify request and response and implement any other functionality. + + 'customization.beforeHandler' => function ($operation, $tableName, $request, $environment) { + $environment->start = microtime(true); + }, + 'customization.afterHandler' => function ($operation, $tableName, $response, $environment) { + return $response->withHeader('X-Time-Taken', microtime(true) - $environment->start); + }, + +The above example will add a header "X-Time-Taken" with the number of seconds the API call has taken. + +### XML middleware + +You may use the "xml" middleware to translate input and output from JSON to XML. This request: + + GET /records/posts/1 + +Outputs (when "pretty printed"): + + { + "id": 1, + "user_id": 1, + "category_id": 1, + "content": "blog started" + } + +While (note the "format" query parameter): + + GET /records/posts/1?format=xml + +Outputs: + + + 1 + 1 + 1 + blog started + + +This functionality is disabled by default and must be enabled using the "middlewares" configuration setting. + +### File uploads + +File uploads are supported through the [FileReader API](https://caniuse.com/#feat=filereader), check out the [example](https://github.com/mevdschee/php-crud-api/blob/master/examples/clients/upload/vanilla.html). + +## OpenAPI specification + +On the "/openapi" end-point the OpenAPI 3.0 (formerly called "Swagger") specification is served. +It is a machine readable instant documentation of your API. To learn more, check out these links: + +- [Swagger Editor](https://editor.swagger.io/) can be used to view and debug the generated specification. +- [OpenAPI specification](https://swagger.io/specification/) is a manual for creating an OpenAPI specification. +- [Swagger Petstore](https://petstore.swagger.io/) is an example documentation that is generated using OpenAPI. + +## Cache + +There are 4 cache engines that can be configured by the "cacheType" config parameter: + +- TempFile (default) +- Redis +- Memcache +- Memcached + +You can install the dependencies for the last three engines by running: + + sudo apt install php-redis redis + sudo apt install php-memcache memcached + sudo apt install php-memcached memcached + +The default engine has no dependencies and will use temporary files in the system "temp" path. + +You may use the "cachePath" config parameter to specify the file system path for the temporary files or +in case that you use a non-default "cacheType" the hostname (optionally with port) of the cache server. + +## Types + +These are the supported types with their length, category, JSON type and format: + +| type | length | category | JSON type | format | +| ---------- | ------ | --------- | --------- | ------------------- | +| varchar | 255 | character | string | | +| clob | | character | string | | +| boolean | | boolean | boolean | | +| integer | | integer | number | | +| bigint | | integer | number | | +| float | | float | number | | +| double | | float | number | | +| decimal | 19,4 | decimal | string | | +| date | | date/time | string | yyyy-mm-dd | +| time | | date/time | string | hh:mm:ss | +| timestamp | | date/time | string | yyyy-mm-dd hh:mm:ss | +| varbinary | 255 | binary | string | base64 encoded | +| blob | | binary | string | base64 encoded | +| geometry | | other | string | well-known text | + +Note that geometry is a non-jdbc type and thus has limited support. + +## Data types in JavaScript + +Javascript and Javascript object notation (JSON) are not very well suited for reading database records. Decimal, date/time, binary and geometry types must be represented as strings in JSON (binary is base64 encoded, geometries are in WKT format). Below are two more serious issues described. + +### 64 bit integers + +JavaScript does not support 64 bit integers. All numbers are stored as 64 bit floating point values. The mantissa of a 64 bit floating point number is only 53 bit and that is why all integer numbers bigger than 53 bit may cause problems in JavaScript. + +### Inf and NaN floats + +The valid floating point values 'Infinite' (calculated with '1/0') and 'Not a Number' (calculated with '0/0') cannot be expressed in JSON, as they are not supported by the [JSON specification](https://www.json.org). When these values are stored in a database then you cannot read them as this script outputs database records as JSON. + +## Errors + +The following errors may be reported: + +| Error | HTTP response code | Message +| ------| -------------------------- | -------------- +| 1000 | 404 Not found | Route not found +| 1001 | 404 Not found | Table not found +| 1002 | 422 Unprocessable entity | Argument count mismatch +| 1003 | 404 Not found | Record not found +| 1004 | 403 Forbidden | Origin is forbidden +| 1005 | 404 Not found | Column not found +| 1006 | 409 Conflict | Table already exists +| 1007 | 409 Conflict | Column already exists +| 1008 | 422 Unprocessable entity | Cannot read HTTP message +| 1009 | 409 Conflict | Duplicate key exception +| 1010 | 409 Conflict | Data integrity violation +| 1011 | 401 Unauthorized | Authentication required +| 1012 | 403 Forbidden | Authentication failed +| 1013 | 422 Unprocessable entity | Input validation failed +| 1014 | 403 Forbidden | Operation forbidden +| 1015 | 405 Method not allowed | Operation not supported +| 1016 | 403 Forbidden | Temporary or permanently blocked +| 1017 | 403 Forbidden | Bad or missing XSRF token +| 1018 | 403 Forbidden | Only AJAX requests allowed +| 1019 | 403 Forbidden | Pagination Forbidden +| 9999 | 500 Internal server error | Unknown error + +The following JSON structure is used: + + { + "code":1002, + "message":"Argument count mismatch in '1'" + } + +NB: Any non-error response will have status: 200 OK + +## Tests + +I am testing mainly on Ubuntu and I have the following test setups: + + - (Docker) Ubuntu 16.04 with PHP 7.0, MariaDB 10.0, PostgreSQL 9.5 (PostGIS 2.2) and SQL Server 2017 + - (Docker) Debian 9 with PHP 7.0, MariaDB 10.1, PostgreSQL 9.6 (PostGIS 2.3) and SQLite 3.16 + - (Docker) Ubuntu 18.04 with PHP 7.2, MySQL 5.7, PostgreSQL 10.4 (PostGIS 2.4) and SQLite 3.22 + - (Docker) Debian 10 with PHP 7.3, MariaDB 10.3, PostgreSQL 11.4 (PostGIS 2.5) and SQLite 3.27 + - (Docker) Ubuntu 20.04 with PHP 7.4, MySQL 8.0, PostgreSQL 12.2 (PostGIS 3.0) and SQLite 3.31 + - (Docker) CentOS 8 with PHP 7.4, MariaDB 10.5, PostgreSQL 12.5 (PostGIS 3.0) and SQLite 3.26 + +This covers not all environments (yet), so please notify me of failing tests and report your environment. +I will try to cover most relevant setups in the "docker" folder of the project. + +### Running + +To run the functional tests locally you may run the following command: + + php test.php + +This runs the functional tests from the "tests" directory. It uses the database dumps (fixtures) and +database configuration (config) from the corresponding subdirectories. + +## Nginx config example +``` +server { + listen 80 default_server; + listen [::]:80 default_server; + + root /var/www/html; + index index.php index.html index.htm index.nginx-debian.html; + server_name server_domain_or_IP; + + location / { + try_files $uri $uri/ =404; + } + + location ~ [^/]\.php(/|$) { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + try_files $fastcgi_script_name =404; + set $path_info $fastcgi_path_info; + fastcgi_param PATH_INFO $path_info; + fastcgi_index index.php; + include fastcgi.conf; + fastcgi_pass unix:/run/php/php7.0-fpm.sock; + } + + location ~ /\.ht { + deny all; + } +} +``` + +### Docker tests + +Install docker using the following commands and then logout and login for the changes to take effect: + + sudo apt install docker.io + sudo usermod -aG docker ${USER} + +To run the docker tests run "build_all.sh" and "run_all.sh" from the docker directory. The output should be: + + ================================================ + CentOS 8 (PHP 7.4) + ================================================ + [1/4] Starting MariaDB 10.5 ..... done + [2/4] Starting PostgreSQL 12.5 .. done + [3/4] Starting SQLServer 2017 ... skipped + [4/4] Cloning PHP-CRUD-API v2 ... skipped + ------------------------------------------------ + mysql: 110 tests ran in 1911 ms, 1 skipped, 0 failed + pgsql: 110 tests ran in 1112 ms, 1 skipped, 0 failed + sqlsrv: skipped, driver not loaded + sqlite: 110 tests ran in 1178 ms, 12 skipped, 0 failed + ================================================ + Debian 10 (PHP 7.3) + ================================================ + [1/4] Starting MariaDB 10.3 ..... done + [2/4] Starting PostgreSQL 11.4 .. done + [3/4] Starting SQLServer 2017 ... skipped + [4/4] Cloning PHP-CRUD-API v2 ... skipped + ------------------------------------------------ + mysql: 110 tests ran in 3459 ms, 1 skipped, 0 failed + pgsql: 110 tests ran in 1134 ms, 1 skipped, 0 failed + sqlsrv: skipped, driver not loaded + sqlite: 110 tests ran in 1275 ms, 12 skipped, 0 failed + ================================================ + Debian 9 (PHP 7.0) + ================================================ + [1/4] Starting MariaDB 10.1 ..... done + [2/4] Starting PostgreSQL 9.6 ... done + [3/4] Starting SQLServer 2017 ... skipped + [4/4] Cloning PHP-CRUD-API v2 ... skipped + ------------------------------------------------ + mysql: 110 tests ran in 3181 ms, 1 skipped, 0 failed + pgsql: 110 tests ran in 1201 ms, 1 skipped, 0 failed + sqlsrv: skipped, driver not loaded + sqlite: 110 tests ran in 1414 ms, 12 skipped, 0 failed + ================================================ + Ubuntu 16.04 (PHP 7.0) + ================================================ + [1/4] Starting MariaDB 10.0 ..... done + [2/4] Starting PostgreSQL 9.5 ... done + [3/4] Starting SQLServer 2017 ... done + [4/4] Cloning PHP-CRUD-API v2 ... skipped + ------------------------------------------------ + mysql: 110 tests ran in 3168 ms, 1 skipped, 0 failed + pgsql: 110 tests ran in 1197 ms, 1 skipped, 0 failed + sqlsrv: 110 tests ran in 10151 ms, 1 skipped, 0 failed + sqlite: skipped, driver not loaded + ================================================ + Ubuntu 18.04 (PHP 7.2) + ================================================ + [1/4] Starting MySQL 5.7 ........ done + [2/4] Starting PostgreSQL 10.4 .. done + [3/4] Starting SQLServer 2017 ... skipped + [4/4] Cloning PHP-CRUD-API v2 ... skipped + ------------------------------------------------ + mysql: 110 tests ran in 3709 ms, 1 skipped, 0 failed + pgsql: 110 tests ran in 1334 ms, 1 skipped, 0 failed + sqlsrv: skipped, driver not loaded + sqlite: 110 tests ran in 1477 ms, 12 skipped, 0 failed + ================================================ + Ubuntu 20.04 (PHP 7.4) + ================================================ + [1/4] Starting MySQL 8.0 ........ done + [2/4] Starting PostgreSQL 12.2 .. done + [3/4] Starting SQLServer 2017 ... skipped + [4/4] Cloning PHP-CRUD-API v2 ... skipped + ------------------------------------------------ + mysql: 110 tests ran in 5102 ms, 1 skipped, 0 failed + pgsql: 110 tests ran in 1170 ms, 1 skipped, 0 failed + sqlsrv: skipped, driver not loaded + sqlite: 110 tests ran in 1380 ms, 12 skipped, 0 failed + +The above test run (including starting up the databases) takes less than 5 minutes on my slow laptop. + + $ ./run.sh + 1) centos8 + 2) debian10 + 3) debian9 + 4) ubuntu16 + 5) ubuntu18 + 6) ubuntu20 + > 5 + ================================================ + Ubuntu 18.04 (PHP 7.2) + ================================================ + [1/4] Starting MySQL 5.7 ........ done + [2/4] Starting PostgreSQL 10.4 .. done + [3/4] Starting SQLServer 2017 ... skipped + [4/4] Cloning PHP-CRUD-API v2 ... skipped + ------------------------------------------------ + mysql: 105 tests ran in 3390 ms, 1 skipped, 0 failed + pgsql: 105 tests ran in 936 ms, 1 skipped, 0 failed + sqlsrv: skipped, driver not loaded + sqlite: 105 tests ran in 1063 ms, 12 skipped, 0 failed + root@b7ab9472e08f:/php-crud-api# + +As you can see the "run.sh" script gives you access to a prompt in a chosen the docker environment. +In this environment the local files are mounted. This allows for easy debugging on different environments. +You may type "exit" when you are done. + +### Docker image + +There is a `Dockerfile` in the repository that is used to build an image at: + +[https://hub.docker.com/r/mevdschee/php-crud-api](https://hub.docker.com/r/mevdschee/php-crud-api) + +It will be automatically build on every release. The "latest" tag points to the last release. + +### Docker compose + +This repository also contains a `docker-compose.yml` file that you can install/build/run using: + + sudo apt install docker-compose + docker-compose build + docker-compose up + +This will setup a database (MySQL) and a webserver (Apache) and runs the application using the blog example data used in the tests. + +Test the script (running in the container) by opening the following URL: + + http://localhost:8080/records/posts/1 + +Enjoy! diff --git a/api.include.php b/api.include.php new file mode 100644 index 0000000..20d074c --- /dev/null +++ b/api.include.php @@ -0,0 +1,11387 @@ +getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return string[][] Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders(); + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name Case-insensitive header field name. + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader($name); + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($name); + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name Case-insensitive header field name. + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine($name); + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader($name, $value); + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader($name, $value); + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader($name); + + /** + * Gets the body of the message. + * + * @return StreamInterface Returns the body as a stream. + */ + public function getBody(); + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody(StreamInterface $body); + } +} + +// file: vendor/psr/http-message/src/RequestInterface.php +namespace Psr\Http\Message { + + /** + * Representation of an outgoing, client-side request. + * + * Per the HTTP specification, this interface includes properties for + * each of the following: + * + * - Protocol version + * - HTTP method + * - URI + * - Headers + * - Message body + * + * During construction, implementations MUST attempt to set the Host header from + * a provided URI if no Host header is provided. + * + * Requests are considered immutable; all methods that might change state MUST + * be implemented such that they retain the internal state of the current + * message and return an instance that contains the changed state. + */ + interface RequestInterface extends MessageInterface + { + /** + * Retrieves the message's request target. + * + * Retrieves the message's request-target either as it will appear (for + * clients), as it appeared at request (for servers), or as it was + * specified for the instance (see withRequestTarget()). + * + * In most cases, this will be the origin-form of the composed URI, + * unless a value was provided to the concrete implementation (see + * withRequestTarget() below). + * + * If no URI is available, and no request-target has been specifically + * provided, this method MUST return the string "/". + * + * @return string + */ + public function getRequestTarget(); + + /** + * Return an instance with the specific request-target. + * + * If the request needs a non-origin-form request-target — e.g., for + * specifying an absolute-form, authority-form, or asterisk-form — + * this method may be used to create an instance with the specified + * request-target, verbatim. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request target. + * + * @link http://tools.ietf.org/html/rfc7230#section-5.3 (for the various + * request-target forms allowed in request messages) + * @param mixed $requestTarget + * @return static + */ + public function withRequestTarget($requestTarget); + + /** + * Retrieves the HTTP method of the request. + * + * @return string Returns the request method. + */ + public function getMethod(); + + /** + * Return an instance with the provided HTTP method. + * + * While HTTP method names are typically all uppercase characters, HTTP + * method names are case-sensitive and thus implementations SHOULD NOT + * modify the given string. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request method. + * + * @param string $method Case-sensitive method. + * @return static + * @throws \InvalidArgumentException for invalid HTTP methods. + */ + public function withMethod($method); + + /** + * Retrieves the URI instance. + * + * This method MUST return a UriInterface instance. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @return UriInterface Returns a UriInterface instance + * representing the URI of the request. + */ + public function getUri(); + + /** + * Returns an instance with the provided URI. + * + * This method MUST update the Host header of the returned request by + * default if the URI contains a host component. If the URI does not + * contain a host component, any pre-existing Host header MUST be carried + * over to the returned request. + * + * You can opt-in to preserving the original state of the Host header by + * setting `$preserveHost` to `true`. When `$preserveHost` is set to + * `true`, this method interacts with the Host header in the following ways: + * + * - If the Host header is missing or empty, and the new URI contains + * a host component, this method MUST update the Host header in the returned + * request. + * - If the Host header is missing or empty, and the new URI does not contain a + * host component, this method MUST NOT update the Host header in the returned + * request. + * - If a Host header is present and non-empty, this method MUST NOT update + * the Host header in the returned request. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new UriInterface instance. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @param UriInterface $uri New request URI to use. + * @param bool $preserveHost Preserve the original state of the Host header. + * @return static + */ + public function withUri(UriInterface $uri, $preserveHost = false); + } +} + +// file: vendor/psr/http-message/src/ResponseInterface.php +namespace Psr\Http\Message { + + /** + * Representation of an outgoing, server-side response. + * + * Per the HTTP specification, this interface includes properties for + * each of the following: + * + * - Protocol version + * - Status code and reason phrase + * - Headers + * - Message body + * + * Responses are considered immutable; all methods that might change state MUST + * be implemented such that they retain the internal state of the current + * message and return an instance that contains the changed state. + */ + interface ResponseInterface extends MessageInterface + { + /** + * Gets the response status code. + * + * The status code is a 3-digit integer result code of the server's attempt + * to understand and satisfy the request. + * + * @return int Status code. + */ + public function getStatusCode(); + + /** + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification. + * @return static + * @throws \InvalidArgumentException For invalid status code arguments. + */ + public function withStatus($code, $reasonPhrase = ''); + + /** + * Gets the response reason phrase associated with the status code. + * + * Because a reason phrase is not a required element in a response + * status line, the reason phrase value MAY be null. Implementations MAY + * choose to return the default RFC 7231 recommended reason phrase (or those + * listed in the IANA HTTP Status Code Registry) for the response's + * status code. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @return string Reason phrase; must return an empty string if none present. + */ + public function getReasonPhrase(); + } +} + +// file: vendor/psr/http-message/src/ServerRequestInterface.php +namespace Psr\Http\Message { + + /** + * Representation of an incoming, server-side HTTP request. + * + * Per the HTTP specification, this interface includes properties for + * each of the following: + * + * - Protocol version + * - HTTP method + * - URI + * - Headers + * - Message body + * + * Additionally, it encapsulates all data as it has arrived to the + * application from the CGI and/or PHP environment, including: + * + * - The values represented in $_SERVER. + * - Any cookies provided (generally via $_COOKIE) + * - Query string arguments (generally via $_GET, or as parsed via parse_str()) + * - Upload files, if any (as represented by $_FILES) + * - Deserialized body parameters (generally from $_POST) + * + * $_SERVER values MUST be treated as immutable, as they represent application + * state at the time of request; as such, no methods are provided to allow + * modification of those values. The other values provide such methods, as they + * can be restored from $_SERVER or the request body, and may need treatment + * during the application (e.g., body parameters may be deserialized based on + * content type). + * + * Additionally, this interface recognizes the utility of introspecting a + * request to derive and match additional parameters (e.g., via URI path + * matching, decrypting cookie values, deserializing non-form-encoded body + * content, matching authorization headers to users, etc). These parameters + * are stored in an "attributes" property. + * + * Requests are considered immutable; all methods that might change state MUST + * be implemented such that they retain the internal state of the current + * message and return an instance that contains the changed state. + */ + interface ServerRequestInterface extends RequestInterface + { + /** + * Retrieve server parameters. + * + * Retrieves data related to the incoming request environment, + * typically derived from PHP's $_SERVER superglobal. The data IS NOT + * REQUIRED to originate from $_SERVER. + * + * @return array + */ + public function getServerParams(); + + /** + * Retrieve cookies. + * + * Retrieves cookies sent by the client to the server. + * + * The data MUST be compatible with the structure of the $_COOKIE + * superglobal. + * + * @return array + */ + public function getCookieParams(); + + /** + * Return an instance with the specified cookies. + * + * The data IS NOT REQUIRED to come from the $_COOKIE superglobal, but MUST + * be compatible with the structure of $_COOKIE. Typically, this data will + * be injected at instantiation. + * + * This method MUST NOT update the related Cookie header of the request + * instance, nor related values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated cookie values. + * + * @param array $cookies Array of key/value pairs representing cookies. + * @return static + */ + public function withCookieParams(array $cookies); + + /** + * Retrieve query string arguments. + * + * Retrieves the deserialized query string arguments, if any. + * + * Note: the query params might not be in sync with the URI or server + * params. If you need to ensure you are only getting the original + * values, you may need to parse the query string from `getUri()->getQuery()` + * or from the `QUERY_STRING` server param. + * + * @return array + */ + public function getQueryParams(); + + /** + * Return an instance with the specified query string arguments. + * + * These values SHOULD remain immutable over the course of the incoming + * request. They MAY be injected during instantiation, such as from PHP's + * $_GET superglobal, or MAY be derived from some other value such as the + * URI. In cases where the arguments are parsed from the URI, the data + * MUST be compatible with what PHP's parse_str() would return for + * purposes of how duplicate query parameters are handled, and how nested + * sets are handled. + * + * Setting query string arguments MUST NOT change the URI stored by the + * request, nor the values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated query string arguments. + * + * @param array $query Array of query string arguments, typically from + * $_GET. + * @return static + */ + public function withQueryParams(array $query); + + /** + * Retrieve normalized file upload data. + * + * This method returns upload metadata in a normalized tree, with each leaf + * an instance of Psr\Http\Message\UploadedFileInterface. + * + * These values MAY be prepared from $_FILES or the message body during + * instantiation, or MAY be injected via withUploadedFiles(). + * + * @return array An array tree of UploadedFileInterface instances; an empty + * array MUST be returned if no data is present. + */ + public function getUploadedFiles(); + + /** + * Create a new instance with the specified uploaded files. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param array $uploadedFiles An array tree of UploadedFileInterface instances. + * @return static + * @throws \InvalidArgumentException if an invalid structure is provided. + */ + public function withUploadedFiles(array $uploadedFiles); + + /** + * Retrieve any parameters provided in the request body. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, this method MUST + * return the contents of $_POST. + * + * Otherwise, this method may return any results of deserializing + * the request body content; as parsing returns structured content, the + * potential types MUST be arrays or objects only. A null value indicates + * the absence of body content. + * + * @return null|array|object The deserialized body parameters, if any. + * These will typically be an array or object. + */ + public function getParsedBody(); + + /** + * Return an instance with the specified body parameters. + * + * These MAY be injected during instantiation. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, use this method + * ONLY to inject the contents of $_POST. + * + * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of + * deserializing the request body content. Deserialization/parsing returns + * structured data, and, as such, this method ONLY accepts arrays or objects, + * or a null value if nothing was available to parse. + * + * As an example, if content negotiation determines that the request data + * is a JSON payload, this method could be used to create a request + * instance with the deserialized parameters. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param null|array|object $data The deserialized body data. This will + * typically be in an array or object. + * @return static + * @throws \InvalidArgumentException if an unsupported argument type is + * provided. + */ + public function withParsedBody($data); + + /** + * Retrieve attributes derived from the request. + * + * The request "attributes" may be used to allow injection of any + * parameters derived from the request: e.g., the results of path + * match operations; the results of decrypting cookies; the results of + * deserializing non-form-encoded message bodies; etc. Attributes + * will be application and request specific, and CAN be mutable. + * + * @return array Attributes derived from the request. + */ + public function getAttributes(); + + /** + * Retrieve a single derived request attribute. + * + * Retrieves a single derived request attribute as described in + * getAttributes(). If the attribute has not been previously set, returns + * the default value as provided. + * + * This method obviates the need for a hasAttribute() method, as it allows + * specifying a default value to return if the attribute is not found. + * + * @see getAttributes() + * @param string $name The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + * @return mixed + */ + public function getAttribute($name, $default = null); + + /** + * Return an instance with the specified derived request attribute. + * + * This method allows setting a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated attribute. + * + * @see getAttributes() + * @param string $name The attribute name. + * @param mixed $value The value of the attribute. + * @return static + */ + public function withAttribute($name, $value); + + /** + * Return an instance that removes the specified derived request attribute. + * + * This method allows removing a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the attribute. + * + * @see getAttributes() + * @param string $name The attribute name. + * @return static + */ + public function withoutAttribute($name); + } +} + +// file: vendor/psr/http-message/src/StreamInterface.php +namespace Psr\Http\Message { + + /** + * Describes a data stream. + * + * Typically, an instance will wrap a PHP stream; this interface provides + * a wrapper around the most common operations, including serialization of + * the entire stream to a string. + */ + interface StreamInterface + { + /** + * Reads all data from the stream into a string, from the beginning to end. + * + * This method MUST attempt to seek to the beginning of the stream before + * reading data and read the stream until the end is reached. + * + * Warning: This could attempt to load a large amount of data into memory. + * + * This method MUST NOT raise an exception in order to conform with PHP's + * string casting operations. + * + * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring + * @return string + */ + public function __toString(); + + /** + * Closes the stream and any underlying resources. + * + * @return void + */ + public function close(); + + /** + * Separates any underlying resources from the stream. + * + * After the stream has been detached, the stream is in an unusable state. + * + * @return resource|null Underlying PHP stream, if any + */ + public function detach(); + + /** + * Get the size of the stream if known. + * + * @return int|null Returns the size in bytes if known, or null if unknown. + */ + public function getSize(); + + /** + * Returns the current position of the file read/write pointer + * + * @return int Position of the file pointer + * @throws \RuntimeException on error. + */ + public function tell(); + + /** + * Returns true if the stream is at the end of the stream. + * + * @return bool + */ + public function eof(); + + /** + * Returns whether or not the stream is seekable. + * + * @return bool + */ + public function isSeekable(); + + /** + * Seek to a position in the stream. + * + * @link http://www.php.net/manual/en/function.fseek.php + * @param int $offset Stream offset + * @param int $whence Specifies how the cursor position will be calculated + * based on the seek offset. Valid values are identical to the built-in + * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to + * offset bytes SEEK_CUR: Set position to current location plus offset + * SEEK_END: Set position to end-of-stream plus offset. + * @throws \RuntimeException on failure. + */ + public function seek($offset, $whence = SEEK_SET); + + /** + * Seek to the beginning of the stream. + * + * If the stream is not seekable, this method will raise an exception; + * otherwise, it will perform a seek(0). + * + * @see seek() + * @link http://www.php.net/manual/en/function.fseek.php + * @throws \RuntimeException on failure. + */ + public function rewind(); + + /** + * Returns whether or not the stream is writable. + * + * @return bool + */ + public function isWritable(); + + /** + * Write data to the stream. + * + * @param string $string The string that is to be written. + * @return int Returns the number of bytes written to the stream. + * @throws \RuntimeException on failure. + */ + public function write($string); + + /** + * Returns whether or not the stream is readable. + * + * @return bool + */ + public function isReadable(); + + /** + * Read data from the stream. + * + * @param int $length Read up to $length bytes from the object and return + * them. Fewer than $length bytes may be returned if underlying stream + * call returns fewer bytes. + * @return string Returns the data read from the stream, or an empty string + * if no bytes are available. + * @throws \RuntimeException if an error occurs. + */ + public function read($length); + + /** + * Returns the remaining contents in a string + * + * @return string + * @throws \RuntimeException if unable to read or an error occurs while + * reading. + */ + public function getContents(); + + /** + * Get stream metadata as an associative array or retrieve a specific key. + * + * The keys returned are identical to the keys returned from PHP's + * stream_get_meta_data() function. + * + * @link http://php.net/manual/en/function.stream-get-meta-data.php + * @param string $key Specific metadata to retrieve. + * @return array|mixed|null Returns an associative array if no key is + * provided. Returns a specific key value if a key is provided and the + * value is found, or null if the key is not found. + */ + public function getMetadata($key = null); + } +} + +// file: vendor/psr/http-message/src/UploadedFileInterface.php +namespace Psr\Http\Message { + + /** + * Value object representing a file uploaded through an HTTP request. + * + * Instances of this interface are considered immutable; all methods that + * might change state MUST be implemented such that they retain the internal + * state of the current instance and return an instance that contains the + * changed state. + */ + interface UploadedFileInterface + { + /** + * Retrieve a stream representing the uploaded file. + * + * This method MUST return a StreamInterface instance, representing the + * uploaded file. The purpose of this method is to allow utilizing native PHP + * stream functionality to manipulate the file upload, such as + * stream_copy_to_stream() (though the result will need to be decorated in a + * native PHP stream wrapper to work with such functions). + * + * If the moveTo() method has been called previously, this method MUST raise + * an exception. + * + * @return StreamInterface Stream representation of the uploaded file. + * @throws \RuntimeException in cases when no stream is available or can be + * created. + */ + public function getStream(); + + /** + * Move the uploaded file to a new location. + * + * Use this method as an alternative to move_uploaded_file(). This method is + * guaranteed to work in both SAPI and non-SAPI environments. + * Implementations must determine which environment they are in, and use the + * appropriate method (move_uploaded_file(), rename(), or a stream + * operation) to perform the operation. + * + * $targetPath may be an absolute path, or a relative path. If it is a + * relative path, resolution should be the same as used by PHP's rename() + * function. + * + * The original file or stream MUST be removed on completion. + * + * If this method is called more than once, any subsequent calls MUST raise + * an exception. + * + * When used in an SAPI environment where $_FILES is populated, when writing + * files via moveTo(), is_uploaded_file() and move_uploaded_file() SHOULD be + * used to ensure permissions and upload status are verified correctly. + * + * If you wish to move to a stream, use getStream(), as SAPI operations + * cannot guarantee writing to stream destinations. + * + * @see http://php.net/is_uploaded_file + * @see http://php.net/move_uploaded_file + * @param string $targetPath Path to which to move the uploaded file. + * @throws \InvalidArgumentException if the $targetPath specified is invalid. + * @throws \RuntimeException on any error during the move operation, or on + * the second or subsequent call to the method. + */ + public function moveTo($targetPath); + + /** + * Retrieve the file size. + * + * Implementations SHOULD return the value stored in the "size" key of + * the file in the $_FILES array if available, as PHP calculates this based + * on the actual size transmitted. + * + * @return int|null The file size in bytes or null if unknown. + */ + public function getSize(); + + /** + * Retrieve the error associated with the uploaded file. + * + * The return value MUST be one of PHP's UPLOAD_ERR_XXX constants. + * + * If the file was uploaded successfully, this method MUST return + * UPLOAD_ERR_OK. + * + * Implementations SHOULD return the value stored in the "error" key of + * the file in the $_FILES array. + * + * @see http://php.net/manual/en/features.file-upload.errors.php + * @return int One of PHP's UPLOAD_ERR_XXX constants. + */ + public function getError(); + + /** + * Retrieve the filename sent by the client. + * + * Do not trust the value returned by this method. A client could send + * a malicious filename with the intention to corrupt or hack your + * application. + * + * Implementations SHOULD return the value stored in the "name" key of + * the file in the $_FILES array. + * + * @return string|null The filename sent by the client or null if none + * was provided. + */ + public function getClientFilename(); + + /** + * Retrieve the media type sent by the client. + * + * Do not trust the value returned by this method. A client could send + * a malicious media type with the intention to corrupt or hack your + * application. + * + * Implementations SHOULD return the value stored in the "type" key of + * the file in the $_FILES array. + * + * @return string|null The media type sent by the client or null if none + * was provided. + */ + public function getClientMediaType(); + } +} + +// file: vendor/psr/http-message/src/UriInterface.php +namespace Psr\Http\Message { + + /** + * Value object representing a URI. + * + * This interface is meant to represent URIs according to RFC 3986 and to + * provide methods for most common operations. Additional functionality for + * working with URIs can be provided on top of the interface or externally. + * Its primary use is for HTTP requests, but may also be used in other + * contexts. + * + * Instances of this interface are considered immutable; all methods that + * might change state MUST be implemented such that they retain the internal + * state of the current instance and return an instance that contains the + * changed state. + * + * Typically the Host header will be also be present in the request message. + * For server-side requests, the scheme will typically be discoverable in the + * server parameters. + * + * @link http://tools.ietf.org/html/rfc3986 (the URI specification) + */ + interface UriInterface + { + /** + * Retrieve the scheme component of the URI. + * + * If no scheme is present, this method MUST return an empty string. + * + * The value returned MUST be normalized to lowercase, per RFC 3986 + * Section 3.1. + * + * The trailing ":" character is not part of the scheme and MUST NOT be + * added. + * + * @see https://tools.ietf.org/html/rfc3986#section-3.1 + * @return string The URI scheme. + */ + public function getScheme(); + + /** + * Retrieve the authority component of the URI. + * + * If no authority information is present, this method MUST return an empty + * string. + * + * The authority syntax of the URI is: + * + *
+         * [user-info@]host[:port]
+         * 
+ * + * If the port component is not set or is the standard port for the current + * scheme, it SHOULD NOT be included. + * + * @see https://tools.ietf.org/html/rfc3986#section-3.2 + * @return string The URI authority, in "[user-info@]host[:port]" format. + */ + public function getAuthority(); + + /** + * Retrieve the user information component of the URI. + * + * If no user information is present, this method MUST return an empty + * string. + * + * If a user is present in the URI, this will return that value; + * additionally, if the password is also present, it will be appended to the + * user value, with a colon (":") separating the values. + * + * The trailing "@" character is not part of the user information and MUST + * NOT be added. + * + * @return string The URI user information, in "username[:password]" format. + */ + public function getUserInfo(); + + /** + * Retrieve the host component of the URI. + * + * If no host is present, this method MUST return an empty string. + * + * The value returned MUST be normalized to lowercase, per RFC 3986 + * Section 3.2.2. + * + * @see http://tools.ietf.org/html/rfc3986#section-3.2.2 + * @return string The URI host. + */ + public function getHost(); + + /** + * Retrieve the port component of the URI. + * + * If a port is present, and it is non-standard for the current scheme, + * this method MUST return it as an integer. If the port is the standard port + * used with the current scheme, this method SHOULD return null. + * + * If no port is present, and no scheme is present, this method MUST return + * a null value. + * + * If no port is present, but a scheme is present, this method MAY return + * the standard port for that scheme, but SHOULD return null. + * + * @return null|int The URI port. + */ + public function getPort(); + + /** + * Retrieve the path component of the URI. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * Normally, the empty path "" and absolute path "/" are considered equal as + * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically + * do this normalization because in contexts with a trimmed base path, e.g. + * the front controller, this difference becomes significant. It's the task + * of the user to handle both "" and "/". + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.3. + * + * As an example, if the value should include a slash ("/") not intended as + * delimiter between path segments, that value MUST be passed in encoded + * form (e.g., "%2F") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.3 + * @return string The URI path. + */ + public function getPath(); + + /** + * Retrieve the query string of the URI. + * + * If no query string is present, this method MUST return an empty string. + * + * The leading "?" character is not part of the query and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.4. + * + * As an example, if a value in a key/value pair of the query string should + * include an ampersand ("&") not intended as a delimiter between values, + * that value MUST be passed in encoded form (e.g., "%26") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.4 + * @return string The URI query string. + */ + public function getQuery(); + + /** + * Retrieve the fragment component of the URI. + * + * If no fragment is present, this method MUST return an empty string. + * + * The leading "#" character is not part of the fragment and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.5. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.5 + * @return string The URI fragment. + */ + public function getFragment(); + + /** + * Return an instance with the specified scheme. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified scheme. + * + * Implementations MUST support the schemes "http" and "https" case + * insensitively, and MAY accommodate other schemes if required. + * + * An empty scheme is equivalent to removing the scheme. + * + * @param string $scheme The scheme to use with the new instance. + * @return static A new instance with the specified scheme. + * @throws \InvalidArgumentException for invalid or unsupported schemes. + */ + public function withScheme($scheme); + + /** + * Return an instance with the specified user information. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified user information. + * + * Password is optional, but the user information MUST include the + * user; an empty string for the user is equivalent to removing user + * information. + * + * @param string $user The user name to use for authority. + * @param null|string $password The password associated with $user. + * @return static A new instance with the specified user information. + */ + public function withUserInfo($user, $password = null); + + /** + * Return an instance with the specified host. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified host. + * + * An empty host value is equivalent to removing the host. + * + * @param string $host The hostname to use with the new instance. + * @return static A new instance with the specified host. + * @throws \InvalidArgumentException for invalid hostnames. + */ + public function withHost($host); + + /** + * Return an instance with the specified port. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified port. + * + * Implementations MUST raise an exception for ports outside the + * established TCP and UDP port ranges. + * + * A null value provided for the port is equivalent to removing the port + * information. + * + * @param null|int $port The port to use with the new instance; a null value + * removes the port information. + * @return static A new instance with the specified port. + * @throws \InvalidArgumentException for invalid ports. + */ + public function withPort($port); + + /** + * Return an instance with the specified path. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified path. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * If the path is intended to be domain-relative rather than path relative then + * it must begin with a slash ("/"). Paths not starting with a slash ("/") + * are assumed to be relative to some base path known to the application or + * consumer. + * + * Users can provide both encoded and decoded path characters. + * Implementations ensure the correct encoding as outlined in getPath(). + * + * @param string $path The path to use with the new instance. + * @return static A new instance with the specified path. + * @throws \InvalidArgumentException for invalid paths. + */ + public function withPath($path); + + /** + * Return an instance with the specified query string. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified query string. + * + * Users can provide both encoded and decoded query characters. + * Implementations ensure the correct encoding as outlined in getQuery(). + * + * An empty query string value is equivalent to removing the query string. + * + * @param string $query The query string to use with the new instance. + * @return static A new instance with the specified query string. + * @throws \InvalidArgumentException for invalid query strings. + */ + public function withQuery($query); + + /** + * Return an instance with the specified URI fragment. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified URI fragment. + * + * Users can provide both encoded and decoded fragment characters. + * Implementations ensure the correct encoding as outlined in getFragment(). + * + * An empty fragment value is equivalent to removing the fragment. + * + * @param string $fragment The fragment to use with the new instance. + * @return static A new instance with the specified fragment. + */ + public function withFragment($fragment); + + /** + * Return the string representation as a URI reference. + * + * Depending on which components of the URI are present, the resulting + * string is either a full URI or relative reference according to RFC 3986, + * Section 4.1. The method concatenates the various components of the URI, + * using the appropriate delimiters: + * + * - If a scheme is present, it MUST be suffixed by ":". + * - If an authority is present, it MUST be prefixed by "//". + * - The path can be concatenated without delimiters. But there are two + * cases where the path has to be adjusted to make the URI reference + * valid as PHP does not allow to throw an exception in __toString(): + * - If the path is rootless and an authority is present, the path MUST + * be prefixed by "/". + * - If the path is starting with more than one "/" and no authority is + * present, the starting slashes MUST be reduced to one. + * - If a query is present, it MUST be prefixed by "?". + * - If a fragment is present, it MUST be prefixed by "#". + * + * @see http://tools.ietf.org/html/rfc3986#section-4.1 + * @return string + */ + public function __toString(); + } +} + +// file: vendor/psr/http-server-handler/src/RequestHandlerInterface.php +namespace Psr\Http\Server { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + + /** + * Handles a server request and produces a response. + * + * An HTTP request handler process an HTTP request in order to produce an + * HTTP response. + */ + interface RequestHandlerInterface + { + /** + * Handles a request and produces a response. + * + * May call other collaborating code to generate the response. + */ + public function handle(ServerRequestInterface $request): ResponseInterface; + } +} + +// file: vendor/psr/http-server-middleware/src/MiddlewareInterface.php +namespace Psr\Http\Server { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + + /** + * Participant in processing a server request and response. + * + * An HTTP middleware component participates in processing an HTTP message: + * by acting on the request, generating the response, or forwarding the + * request to a subsequent middleware and possibly acting on its response. + */ + interface MiddlewareInterface + { + /** + * Process an incoming server request. + * + * Processes an incoming server request in order to produce a response. + * If unable to produce the response itself, it may delegate to the provided + * request handler to do so. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; + } +} + +// file: vendor/nyholm/psr7/src/Factory/Psr17Factory.php +namespace Nyholm\Psr7\Factory { + + use Nyholm\Psr7\{Request, Response, ServerRequest, Stream, UploadedFile, Uri}; + use Psr\Http\Message\{RequestFactoryInterface, RequestInterface, ResponseFactoryInterface, ResponseInterface, ServerRequestFactoryInterface, ServerRequestInterface, StreamFactoryInterface, StreamInterface, UploadedFileFactoryInterface, UploadedFileInterface, UriFactoryInterface, UriInterface}; + + /** + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface + { + public function createRequest(string $method, $uri): RequestInterface + { + return new Request($method, $uri); + } + + public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface + { + if (2 > \func_num_args()) { + // This will make the Response class to use a custom reasonPhrase + $reasonPhrase = null; + } + + return new Response($code, [], null, '1.1', $reasonPhrase); + } + + public function createStream(string $content = ''): StreamInterface + { + return Stream::create($content); + } + + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + $resource = @\fopen($filename, $mode); + if (false === $resource) { + if ('' === $mode || false === \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'])) { + throw new \InvalidArgumentException('The mode ' . $mode . ' is invalid.'); + } + + throw new \RuntimeException('The file ' . $filename . ' cannot be opened.'); + } + + return Stream::create($resource); + } + + public function createStreamFromResource($resource): StreamInterface + { + return Stream::create($resource); + } + + public function createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null): UploadedFileInterface + { + if (null === $size) { + $size = $stream->getSize(); + } + + return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType); + } + + public function createUri(string $uri = ''): UriInterface + { + return new Uri($uri); + } + + public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface + { + return new ServerRequest($method, $uri, [], null, '1.1', $serverParams); + } + } +} + +// file: vendor/nyholm/psr7/src/MessageTrait.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\StreamInterface; + + /** + * Trait implementing functionality common to requests and responses. + * + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + * + * @internal should not be used outside of Nyholm/Psr7 as it does not fall under our BC promise + */ + trait MessageTrait + { + /** @var array Map of all registered headers, as original name => array of values */ + private $headers = []; + + /** @var array Map of lowercase header name => original name at registration */ + private $headerNames = []; + + /** @var string */ + private $protocol = '1.1'; + + /** @var StreamInterface|null */ + private $stream; + + public function getProtocolVersion(): string + { + return $this->protocol; + } + + public function withProtocolVersion($version): self + { + if ($this->protocol === $version) { + return $this; + } + + $new = clone $this; + $new->protocol = $version; + + return $new; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function hasHeader($header): bool + { + return isset($this->headerNames[\strtolower($header)]); + } + + public function getHeader($header): array + { + $header = \strtolower($header); + if (!isset($this->headerNames[$header])) { + return []; + } + + $header = $this->headerNames[$header]; + + return $this->headers[$header]; + } + + public function getHeaderLine($header): string + { + return \implode(', ', $this->getHeader($header)); + } + + public function withHeader($header, $value): self + { + $value = $this->validateAndTrimHeader($header, $value); + $normalized = \strtolower($header); + + $new = clone $this; + if (isset($new->headerNames[$normalized])) { + unset($new->headers[$new->headerNames[$normalized]]); + } + $new->headerNames[$normalized] = $header; + $new->headers[$header] = $value; + + return $new; + } + + public function withAddedHeader($header, $value): self + { + if (!\is_string($header) || '' === $header) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + } + + $new = clone $this; + $new->setHeaders([$header => $value]); + + return $new; + } + + public function withoutHeader($header): self + { + $normalized = \strtolower($header); + if (!isset($this->headerNames[$normalized])) { + return $this; + } + + $header = $this->headerNames[$normalized]; + $new = clone $this; + unset($new->headers[$header], $new->headerNames[$normalized]); + + return $new; + } + + public function getBody(): StreamInterface + { + if (null === $this->stream) { + $this->stream = Stream::create(''); + } + + return $this->stream; + } + + public function withBody(StreamInterface $body): self + { + if ($body === $this->stream) { + return $this; + } + + $new = clone $this; + $new->stream = $body; + + return $new; + } + + private function setHeaders(array $headers) /*:void*/ + { + foreach ($headers as $header => $value) { + $value = $this->validateAndTrimHeader($header, $value); + $normalized = \strtolower($header); + if (isset($this->headerNames[$normalized])) { + $header = $this->headerNames[$normalized]; + $this->headers[$header] = \array_merge($this->headers[$header], $value); + } else { + $this->headerNames[$normalized] = $header; + $this->headers[$header] = $value; + } + } + } + + /** + * Make sure the header complies with RFC 7230. + * + * Header names must be a non-empty string consisting of token characters. + * + * Header values must be strings consisting of visible characters with all optional + * leading and trailing whitespace stripped. This method will always strip such + * optional whitespace. Note that the method does not allow folding whitespace within + * the values as this was deprecated for almost all instances by the RFC. + * + * header-field = field-name ":" OWS field-value OWS + * field-name = 1*( "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" + * / "_" / "`" / "|" / "~" / %x30-39 / ( %x41-5A / %x61-7A ) ) + * OWS = *( SP / HTAB ) + * field-value = *( ( %x21-7E / %x80-FF ) [ 1*( SP / HTAB ) ( %x21-7E / %x80-FF ) ] ) + * + * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 + */ + private function validateAndTrimHeader($header, $values): array + { + if (!\is_string($header) || 1 !== \preg_match("@^[!#$%&'*+.^_`|~0-9A-Za-z-]+$@", $header)) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + } + + if (!\is_array($values)) { + // This is simple, just one value. + if ((!\is_numeric($values) && !\is_string($values)) || 1 !== \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $values)) { + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); + } + + return [\trim((string) $values, " \t")]; + } + + if (empty($values)) { + throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given.'); + } + + // Assert Non empty array + $returnValues = []; + foreach ($values as $v) { + if ((!\is_numeric($v) && !\is_string($v)) || 1 !== \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $v)) { + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); + } + + $returnValues[] = \trim((string) $v, " \t"); + } + + return $returnValues; + } + } +} + +// file: vendor/nyholm/psr7/src/Request.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\{RequestInterface, StreamInterface, UriInterface}; + + /** + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Request implements RequestInterface + { + use MessageTrait; + use RequestTrait; + + /** + * @param string $method HTTP method + * @param string|UriInterface $uri URI + * @param array $headers Request headers + * @param string|resource|StreamInterface|null $body Request body + * @param string $version Protocol version + */ + public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1') + { + if (!($uri instanceof UriInterface)) { + $uri = new Uri($uri); + } + + $this->method = $method; + $this->uri = $uri; + $this->setHeaders($headers); + $this->protocol = $version; + + if (!$this->hasHeader('Host')) { + $this->updateHostFromUri(); + } + + // If we got no body, defer initialization of the stream until Request::getBody() + if ('' !== $body && null !== $body) { + $this->stream = Stream::create($body); + } + } + } +} + +// file: vendor/nyholm/psr7/src/RequestTrait.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\UriInterface; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + * + * @internal should not be used outside of Nyholm/Psr7 as it does not fall under our BC promise + */ + trait RequestTrait + { + /** @var string */ + private $method; + + /** @var string|null */ + private $requestTarget; + + /** @var UriInterface|null */ + private $uri; + + public function getRequestTarget(): string + { + if (null !== $this->requestTarget) { + return $this->requestTarget; + } + + if ('' === $target = $this->uri->getPath()) { + $target = '/'; + } + if ('' !== $this->uri->getQuery()) { + $target .= '?' . $this->uri->getQuery(); + } + + return $target; + } + + public function withRequestTarget($requestTarget): self + { + if (\preg_match('#\s#', $requestTarget)) { + throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace'); + } + + $new = clone $this; + $new->requestTarget = $requestTarget; + + return $new; + } + + public function getMethod(): string + { + return $this->method; + } + + public function withMethod($method): self + { + if (!\is_string($method)) { + throw new \InvalidArgumentException('Method must be a string'); + } + + $new = clone $this; + $new->method = $method; + + return $new; + } + + public function getUri(): UriInterface + { + return $this->uri; + } + + public function withUri(UriInterface $uri, $preserveHost = false): self + { + if ($uri === $this->uri) { + return $this; + } + + $new = clone $this; + $new->uri = $uri; + + if (!$preserveHost || !$this->hasHeader('Host')) { + $new->updateHostFromUri(); + } + + return $new; + } + + private function updateHostFromUri() /*:void*/ + { + if ('' === $host = $this->uri->getHost()) { + return; + } + + if (null !== ($port = $this->uri->getPort())) { + $host .= ':' . $port; + } + + if (isset($this->headerNames['host'])) { + $header = $this->headerNames['host']; + } else { + $this->headerNames['host'] = $header = 'Host'; + } + + // Ensure Host is the first header. + // See: http://tools.ietf.org/html/rfc7230#section-5.4 + $this->headers = [$header => [$host]] + $this->headers; + } + } +} + +// file: vendor/nyholm/psr7/src/Response.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\{ResponseInterface, StreamInterface}; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Response implements ResponseInterface + { + use MessageTrait; + + /** @var array Map of standard HTTP status code/reason phrases */ + /*private*/ const PHRASES = [ + 100 => 'Continue', 101 => 'Switching Protocols', 102 => 'Processing', + 200 => 'OK', 201 => 'Created', 202 => 'Accepted', 203 => 'Non-Authoritative Information', 204 => 'No Content', 205 => 'Reset Content', 206 => 'Partial Content', 207 => 'Multi-status', 208 => 'Already Reported', + 300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 305 => 'Use Proxy', 306 => 'Switch Proxy', 307 => 'Temporary Redirect', + 400 => 'Bad Request', 401 => 'Unauthorized', 402 => 'Payment Required', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 406 => 'Not Acceptable', 407 => 'Proxy Authentication Required', 408 => 'Request Time-out', 409 => 'Conflict', 410 => 'Gone', 411 => 'Length Required', 412 => 'Precondition Failed', 413 => 'Request Entity Too Large', 414 => 'Request-URI Too Large', 415 => 'Unsupported Media Type', 416 => 'Requested range not satisfiable', 417 => 'Expectation Failed', 418 => 'I\'m a teapot', 422 => 'Unprocessable Entity', 423 => 'Locked', 424 => 'Failed Dependency', 425 => 'Unordered Collection', 426 => 'Upgrade Required', 428 => 'Precondition Required', 429 => 'Too Many Requests', 431 => 'Request Header Fields Too Large', 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Time-out', 505 => 'HTTP Version not supported', 506 => 'Variant Also Negotiates', 507 => 'Insufficient Storage', 508 => 'Loop Detected', 511 => 'Network Authentication Required', + ]; + + /** @var string */ + private $reasonPhrase = ''; + + /** @var int */ + private $statusCode; + + /** + * @param int $status Status code + * @param array $headers Response headers + * @param string|resource|StreamInterface|null $body Response body + * @param string $version Protocol version + * @param string|null $reason Reason phrase (when empty a default will be used based on the status code) + */ + public function __construct(int $status = 200, array $headers = [], $body = null, string $version = '1.1', string $reason = null) + { + // If we got no body, defer initialization of the stream until Response::getBody() + if ('' !== $body && null !== $body) { + $this->stream = Stream::create($body); + } + + $this->statusCode = $status; + $this->setHeaders($headers); + if (null === $reason && isset(self::PHRASES[$this->statusCode])) { + $this->reasonPhrase = self::PHRASES[$status]; + } else { + $this->reasonPhrase = $reason ?? ''; + } + + $this->protocol = $version; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getReasonPhrase(): string + { + return $this->reasonPhrase; + } + + public function withStatus($code, $reasonPhrase = ''): self + { + if (!\is_int($code) && !\is_string($code)) { + throw new \InvalidArgumentException('Status code has to be an integer'); + } + + $code = (int) $code; + if ($code < 100 || $code > 599) { + throw new \InvalidArgumentException('Status code has to be an integer between 100 and 599'); + } + + $new = clone $this; + $new->statusCode = $code; + if ((null === $reasonPhrase || '' === $reasonPhrase) && isset(self::PHRASES[$new->statusCode])) { + $reasonPhrase = self::PHRASES[$new->statusCode]; + } + $new->reasonPhrase = $reasonPhrase; + + return $new; + } + } +} + +// file: vendor/nyholm/psr7/src/ServerRequest.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\{ServerRequestInterface, StreamInterface, UploadedFileInterface, UriInterface}; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class ServerRequest implements ServerRequestInterface + { + use MessageTrait; + use RequestTrait; + + /** @var array */ + private $attributes = []; + + /** @var array */ + private $cookieParams = []; + + /** @var array|object|null */ + private $parsedBody; + + /** @var array */ + private $queryParams = []; + + /** @var array */ + private $serverParams; + + /** @var UploadedFileInterface[] */ + private $uploadedFiles = []; + + /** + * @param string $method HTTP method + * @param string|UriInterface $uri URI + * @param array $headers Request headers + * @param string|resource|StreamInterface|null $body Request body + * @param string $version Protocol version + * @param array $serverParams Typically the $_SERVER superglobal + */ + public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1', array $serverParams = []) + { + $this->serverParams = $serverParams; + + if (!($uri instanceof UriInterface)) { + $uri = new Uri($uri); + } + + $this->method = $method; + $this->uri = $uri; + $this->setHeaders($headers); + $this->protocol = $version; + + if (!$this->hasHeader('Host')) { + $this->updateHostFromUri(); + } + + // If we got no body, defer initialization of the stream until ServerRequest::getBody() + if ('' !== $body && null !== $body) { + $this->stream = Stream::create($body); + } + } + + public function getServerParams(): array + { + return $this->serverParams; + } + + public function getUploadedFiles(): array + { + return $this->uploadedFiles; + } + + public function withUploadedFiles(array $uploadedFiles) + { + $new = clone $this; + $new->uploadedFiles = $uploadedFiles; + + return $new; + } + + public function getCookieParams(): array + { + return $this->cookieParams; + } + + public function withCookieParams(array $cookies) + { + $new = clone $this; + $new->cookieParams = $cookies; + + return $new; + } + + public function getQueryParams(): array + { + return $this->queryParams; + } + + public function withQueryParams(array $query) + { + $new = clone $this; + $new->queryParams = $query; + + return $new; + } + + public function getParsedBody() + { + return $this->parsedBody; + } + + public function withParsedBody($data) + { + if (!\is_array($data) && !\is_object($data) && null !== $data) { + throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null'); + } + + $new = clone $this; + $new->parsedBody = $data; + + return $new; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttribute($attribute, $default = null) + { + if (false === \array_key_exists($attribute, $this->attributes)) { + return $default; + } + + return $this->attributes[$attribute]; + } + + public function withAttribute($attribute, $value): self + { + $new = clone $this; + $new->attributes[$attribute] = $value; + + return $new; + } + + public function withoutAttribute($attribute): self + { + if (false === \array_key_exists($attribute, $this->attributes)) { + return $this; + } + + $new = clone $this; + unset($new->attributes[$attribute]); + + return $new; + } + } +} + +// file: vendor/nyholm/psr7/src/Stream.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\StreamInterface; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Stream implements StreamInterface + { + /** @var resource|null A resource reference */ + private $stream; + + /** @var bool */ + private $seekable; + + /** @var bool */ + private $readable; + + /** @var bool */ + private $writable; + + /** @var array|mixed|void|null */ + private $uri; + + /** @var int|null */ + private $size; + + /** @var array Hash of readable and writable stream types */ + /*private*/ const READ_WRITE_HASH = [ + 'read' => [ + 'r' => true, 'w+' => true, 'r+' => true, 'x+' => true, 'c+' => true, + 'rb' => true, 'w+b' => true, 'r+b' => true, 'x+b' => true, + 'c+b' => true, 'rt' => true, 'w+t' => true, 'r+t' => true, + 'x+t' => true, 'c+t' => true, 'a+' => true, + ], + 'write' => [ + 'w' => true, 'w+' => true, 'rw' => true, 'r+' => true, 'x+' => true, + 'c+' => true, 'wb' => true, 'w+b' => true, 'r+b' => true, + 'x+b' => true, 'c+b' => true, 'w+t' => true, 'r+t' => true, + 'x+t' => true, 'c+t' => true, 'a' => true, 'a+' => true, + ], + ]; + + private function __construct() + { + } + + /** + * Creates a new PSR-7 stream. + * + * @param string|resource|StreamInterface $body + * + * @return StreamInterface + * + * @throws \InvalidArgumentException + */ + public static function create($body = ''): StreamInterface + { + if ($body instanceof StreamInterface) { + return $body; + } + + if (\is_string($body)) { + $resource = \fopen('php://temp', 'rw+'); + \fwrite($resource, $body); + $body = $resource; + } + + if (\is_resource($body)) { + $new = new self(); + $new->stream = $body; + $meta = \stream_get_meta_data($new->stream); + $new->seekable = $meta['seekable'] && 0 === \fseek($new->stream, 0, \SEEK_CUR); + $new->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); + $new->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); + $new->uri = $new->getMetadata('uri'); + + return $new; + } + + throw new \InvalidArgumentException('First argument to Stream::create() must be a string, resource or StreamInterface.'); + } + + /** + * Closes the stream when the destructed. + */ + public function __destruct() + { + $this->close(); + } + + public function __toString(): string + { + try { + if ($this->isSeekable()) { + $this->seek(0); + } + + return $this->getContents(); + } catch (\Exception $e) { + return ''; + } + } + + public function close() /*:void*/ + { + if (isset($this->stream)) { + if (\is_resource($this->stream)) { + \fclose($this->stream); + } + $this->detach(); + } + } + + public function detach() + { + if (!isset($this->stream)) { + return null; + } + + $result = $this->stream; + unset($this->stream); + $this->size = $this->uri = null; + $this->readable = $this->writable = $this->seekable = false; + + return $result; + } + + public function getSize() /*:?int*/ + { + if (null !== $this->size) { + return $this->size; + } + + if (!isset($this->stream)) { + return null; + } + + // Clear the stat cache if the stream has a URI + if ($this->uri) { + \clearstatcache(true, $this->uri); + } + + $stats = \fstat($this->stream); + if (isset($stats['size'])) { + $this->size = $stats['size']; + + return $this->size; + } + + return null; + } + + public function tell(): int + { + if (false === $result = \ftell($this->stream)) { + throw new \RuntimeException('Unable to determine stream position'); + } + + return $result; + } + + public function eof(): bool + { + return !$this->stream || \feof($this->stream); + } + + public function isSeekable(): bool + { + return $this->seekable; + } + + public function seek($offset, $whence = \SEEK_SET) /*:void*/ + { + if (!$this->seekable) { + throw new \RuntimeException('Stream is not seekable'); + } + + if (-1 === \fseek($this->stream, $offset, $whence)) { + throw new \RuntimeException('Unable to seek to stream position ' . $offset . ' with whence ' . \var_export($whence, true)); + } + } + + public function rewind() /*:void*/ + { + $this->seek(0); + } + + public function isWritable(): bool + { + return $this->writable; + } + + public function write($string): int + { + if (!$this->writable) { + throw new \RuntimeException('Cannot write to a non-writable stream'); + } + + // We can't know the size after writing anything + $this->size = null; + + if (false === $result = \fwrite($this->stream, $string)) { + throw new \RuntimeException('Unable to write to stream'); + } + + return $result; + } + + public function isReadable(): bool + { + return $this->readable; + } + + public function read($length): string + { + if (!$this->readable) { + throw new \RuntimeException('Cannot read from non-readable stream'); + } + + return \fread($this->stream, $length); + } + + public function getContents(): string + { + if (!isset($this->stream)) { + throw new \RuntimeException('Unable to read stream contents'); + } + + if (false === $contents = \stream_get_contents($this->stream)) { + throw new \RuntimeException('Unable to read stream contents'); + } + + return $contents; + } + + public function getMetadata($key = null) + { + if (!isset($this->stream)) { + return $key ? null : []; + } + + $meta = \stream_get_meta_data($this->stream); + + if (null === $key) { + return $meta; + } + + return $meta[$key] ?? null; + } + } +} + +// file: vendor/nyholm/psr7/src/UploadedFile.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\{StreamInterface, UploadedFileInterface}; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class UploadedFile implements UploadedFileInterface + { + /** @var array */ + /*private*/ const ERRORS = [ + \UPLOAD_ERR_OK => 1, + \UPLOAD_ERR_INI_SIZE => 1, + \UPLOAD_ERR_FORM_SIZE => 1, + \UPLOAD_ERR_PARTIAL => 1, + \UPLOAD_ERR_NO_FILE => 1, + \UPLOAD_ERR_NO_TMP_DIR => 1, + \UPLOAD_ERR_CANT_WRITE => 1, + \UPLOAD_ERR_EXTENSION => 1, + ]; + + /** @var string */ + private $clientFilename; + + /** @var string */ + private $clientMediaType; + + /** @var int */ + private $error; + + /** @var string|null */ + private $file; + + /** @var bool */ + private $moved = false; + + /** @var int */ + private $size; + + /** @var StreamInterface|null */ + private $stream; + + /** + * @param StreamInterface|string|resource $streamOrFile + * @param int $size + * @param int $errorStatus + * @param string|null $clientFilename + * @param string|null $clientMediaType + */ + public function __construct($streamOrFile, $size, $errorStatus, $clientFilename = null, $clientMediaType = null) + { + if (false === \is_int($errorStatus) || !isset(self::ERRORS[$errorStatus])) { + throw new \InvalidArgumentException('Upload file error status must be an integer value and one of the "UPLOAD_ERR_*" constants.'); + } + + if (false === \is_int($size)) { + throw new \InvalidArgumentException('Upload file size must be an integer'); + } + + if (null !== $clientFilename && !\is_string($clientFilename)) { + throw new \InvalidArgumentException('Upload file client filename must be a string or null'); + } + + if (null !== $clientMediaType && !\is_string($clientMediaType)) { + throw new \InvalidArgumentException('Upload file client media type must be a string or null'); + } + + $this->error = $errorStatus; + $this->size = $size; + $this->clientFilename = $clientFilename; + $this->clientMediaType = $clientMediaType; + + if (\UPLOAD_ERR_OK === $this->error) { + // Depending on the value set file or stream variable. + if (\is_string($streamOrFile)) { + $this->file = $streamOrFile; + } elseif (\is_resource($streamOrFile)) { + $this->stream = Stream::create($streamOrFile); + } elseif ($streamOrFile instanceof StreamInterface) { + $this->stream = $streamOrFile; + } else { + throw new \InvalidArgumentException('Invalid stream or file provided for UploadedFile'); + } + } + } + + /** + * @throws \RuntimeException if is moved or not ok + */ + private function validateActive() /*:void*/ + { + if (\UPLOAD_ERR_OK !== $this->error) { + throw new \RuntimeException('Cannot retrieve stream due to upload error'); + } + + if ($this->moved) { + throw new \RuntimeException('Cannot retrieve stream after it has already been moved'); + } + } + + public function getStream(): StreamInterface + { + $this->validateActive(); + + if ($this->stream instanceof StreamInterface) { + return $this->stream; + } + + $resource = \fopen($this->file, 'r'); + + return Stream::create($resource); + } + + public function moveTo($targetPath) /*:void*/ + { + $this->validateActive(); + + if (!\is_string($targetPath) || '' === $targetPath) { + throw new \InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + } + + if (null !== $this->file) { + $this->moved = 'cli' === \PHP_SAPI ? \rename($this->file, $targetPath) : \move_uploaded_file($this->file, $targetPath); + } else { + $stream = $this->getStream(); + if ($stream->isSeekable()) { + $stream->rewind(); + } + + // Copy the contents of a stream into another stream until end-of-file. + $dest = Stream::create(\fopen($targetPath, 'w')); + while (!$stream->eof()) { + if (!$dest->write($stream->read(1048576))) { + break; + } + } + + $this->moved = true; + } + + if (false === $this->moved) { + throw new \RuntimeException(\sprintf('Uploaded file could not be moved to %s', $targetPath)); + } + } + + public function getSize(): int + { + return $this->size; + } + + public function getError(): int + { + return $this->error; + } + + public function getClientFilename() /*:?string*/ + { + return $this->clientFilename; + } + + public function getClientMediaType() /*:?string*/ + { + return $this->clientMediaType; + } + } +} + +// file: vendor/nyholm/psr7/src/Uri.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\UriInterface; + + /** + * PSR-7 URI implementation. + * + * @author Michael Dowling + * @author Tobias Schultze + * @author Matthew Weier O'Phinney + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Uri implements UriInterface + { + /*private*/ const SCHEMES = ['http' => 80, 'https' => 443]; + + /*private*/ const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; + + /*private*/ const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; + + /** @var string Uri scheme. */ + private $scheme = ''; + + /** @var string Uri user info. */ + private $userInfo = ''; + + /** @var string Uri host. */ + private $host = ''; + + /** @var int|null Uri port. */ + private $port; + + /** @var string Uri path. */ + private $path = ''; + + /** @var string Uri query string. */ + private $query = ''; + + /** @var string Uri fragment. */ + private $fragment = ''; + + public function __construct(string $uri = '') + { + if ('' !== $uri) { + if (false === $parts = \parse_url($uri)) { + throw new \InvalidArgumentException("Unable to parse URI: $uri"); + } + + // Apply parse_url parts to a URI. + $this->scheme = isset($parts['scheme']) ? \strtolower($parts['scheme']) : ''; + $this->userInfo = $parts['user'] ?? ''; + $this->host = isset($parts['host']) ? \strtolower($parts['host']) : ''; + $this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null; + $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; + $this->query = isset($parts['query']) ? $this->filterQueryAndFragment($parts['query']) : ''; + $this->fragment = isset($parts['fragment']) ? $this->filterQueryAndFragment($parts['fragment']) : ''; + if (isset($parts['pass'])) { + $this->userInfo .= ':' . $parts['pass']; + } + } + } + + public function __toString(): string + { + return self::createUriString($this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment); + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getAuthority(): string + { + if ('' === $this->host) { + return ''; + } + + $authority = $this->host; + if ('' !== $this->userInfo) { + $authority = $this->userInfo . '@' . $authority; + } + + if (null !== $this->port) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + public function getUserInfo(): string + { + return $this->userInfo; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort() /*:?int*/ + { + return $this->port; + } + + public function getPath(): string + { + return $this->path; + } + + public function getQuery(): string + { + return $this->query; + } + + public function getFragment(): string + { + return $this->fragment; + } + + public function withScheme($scheme): self + { + if (!\is_string($scheme)) { + throw new \InvalidArgumentException('Scheme must be a string'); + } + + if ($this->scheme === $scheme = \strtolower($scheme)) { + return $this; + } + + $new = clone $this; + $new->scheme = $scheme; + $new->port = $new->filterPort($new->port); + + return $new; + } + + public function withUserInfo($user, $password = null): self + { + $info = $user; + if (null !== $password && '' !== $password) { + $info .= ':' . $password; + } + + if ($this->userInfo === $info) { + return $this; + } + + $new = clone $this; + $new->userInfo = $info; + + return $new; + } + + public function withHost($host): self + { + if (!\is_string($host)) { + throw new \InvalidArgumentException('Host must be a string'); + } + + if ($this->host === $host = \strtolower($host)) { + return $this; + } + + $new = clone $this; + $new->host = $host; + + return $new; + } + + public function withPort($port): self + { + if ($this->port === $port = $this->filterPort($port)) { + return $this; + } + + $new = clone $this; + $new->port = $port; + + return $new; + } + + public function withPath($path): self + { + if ($this->path === $path = $this->filterPath($path)) { + return $this; + } + + $new = clone $this; + $new->path = $path; + + return $new; + } + + public function withQuery($query): self + { + if ($this->query === $query = $this->filterQueryAndFragment($query)) { + return $this; + } + + $new = clone $this; + $new->query = $query; + + return $new; + } + + public function withFragment($fragment): self + { + if ($this->fragment === $fragment = $this->filterQueryAndFragment($fragment)) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + /** + * Create a URI string from its various parts. + */ + private static function createUriString(string $scheme, string $authority, string $path, string $query, string $fragment): string + { + $uri = ''; + if ('' !== $scheme) { + $uri .= $scheme . ':'; + } + + if ('' !== $authority) { + $uri .= '//' . $authority; + } + + if ('' !== $path) { + if ('/' !== $path[0]) { + if ('' !== $authority) { + // If the path is rootless and an authority is present, the path MUST be prefixed by "/" + $path = '/' . $path; + } + } elseif (isset($path[1]) && '/' === $path[1]) { + if ('' === $authority) { + // If the path is starting with more than one "/" and no authority is present, the + // starting slashes MUST be reduced to one. + $path = '/' . \ltrim($path, '/'); + } + } + + $uri .= $path; + } + + if ('' !== $query) { + $uri .= '?' . $query; + } + + if ('' !== $fragment) { + $uri .= '#' . $fragment; + } + + return $uri; + } + + /** + * Is a given port non-standard for the current scheme? + */ + private static function isNonStandardPort(string $scheme, int $port): bool + { + return !isset(self::SCHEMES[$scheme]) || $port !== self::SCHEMES[$scheme]; + } + + private function filterPort($port) /*:?int*/ + { + if (null === $port) { + return null; + } + + $port = (int) $port; + if (0 > $port || 0xffff < $port) { + throw new \InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port)); + } + + return self::isNonStandardPort($this->scheme, $port) ? $port : null; + } + + private function filterPath($path): string + { + if (!\is_string($path)) { + throw new \InvalidArgumentException('Path must be a string'); + } + + return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $path); + } + + private function filterQueryAndFragment($str): string + { + if (!\is_string($str)) { + throw new \InvalidArgumentException('Query and fragment must be a string'); + } + + return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $str); + } + + private static function rawurlencodeMatchZero(array $match): string + { + return \rawurlencode($match[0]); + } + } +} + +// file: vendor/nyholm/psr7-server/src/ServerRequestCreator.php +namespace Nyholm\Psr7Server { + + use Psr\Http\Message\ServerRequestFactoryInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Message\StreamFactoryInterface; + use Psr\Http\Message\StreamInterface; + use Psr\Http\Message\UploadedFileFactoryInterface; + use Psr\Http\Message\UploadedFileInterface; + use Psr\Http\Message\UriFactoryInterface; + use Psr\Http\Message\UriInterface; + + /** + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class ServerRequestCreator implements ServerRequestCreatorInterface + { + private $serverRequestFactory; + + private $uriFactory; + + private $uploadedFileFactory; + + private $streamFactory; + + public function __construct( + ServerRequestFactoryInterface $serverRequestFactory, + UriFactoryInterface $uriFactory, + UploadedFileFactoryInterface $uploadedFileFactory, + StreamFactoryInterface $streamFactory + ) { + $this->serverRequestFactory = $serverRequestFactory; + $this->uriFactory = $uriFactory; + $this->uploadedFileFactory = $uploadedFileFactory; + $this->streamFactory = $streamFactory; + } + + /** + * {@inheritdoc} + */ + public function fromGlobals(): ServerRequestInterface + { + $server = $_SERVER; + if (false === isset($server['REQUEST_METHOD'])) { + $server['REQUEST_METHOD'] = 'GET'; + } + + $headers = \function_exists('getallheaders') ? getallheaders() : static::getHeadersFromServer($_SERVER); + + return $this->fromArrays($server, $headers, $_COOKIE, $_GET, $_POST, $_FILES, \fopen('php://input', 'r') ?: null); + } + + /** + * {@inheritdoc} + */ + public function fromArrays(array $server, array $headers = [], array $cookie = [], array $get = [], array $post = [], array $files = [], $body = null): ServerRequestInterface + { + $method = $this->getMethodFromEnv($server); + $uri = $this->getUriFromEnvWithHTTP($server); + $protocol = isset($server['SERVER_PROTOCOL']) ? \str_replace('HTTP/', '', $server['SERVER_PROTOCOL']) : '1.1'; + + $serverRequest = $this->serverRequestFactory->createServerRequest($method, $uri, $server); + foreach ($headers as $name => $value) { + $serverRequest = $serverRequest->withAddedHeader($name, $value); + } + + $serverRequest = $serverRequest + ->withProtocolVersion($protocol) + ->withCookieParams($cookie) + ->withQueryParams($get) + ->withParsedBody($post) + ->withUploadedFiles($this->normalizeFiles($files)); + + if (null === $body) { + return $serverRequest; + } + + if (\is_resource($body)) { + $body = $this->streamFactory->createStreamFromResource($body); + } elseif (\is_string($body)) { + $body = $this->streamFactory->createStream($body); + } elseif (!$body instanceof StreamInterface) { + throw new \InvalidArgumentException('The $body parameter to ServerRequestCreator::fromArrays must be string, resource or StreamInterface'); + } + + return $serverRequest->withBody($body); + } + + /** + * Implementation from Zend\Diactoros\marshalHeadersFromSapi(). + */ + public static function getHeadersFromServer(array $server): array + { + $headers = []; + foreach ($server as $key => $value) { + // Apache prefixes environment variables with REDIRECT_ + // if they are added by rewrite rules + if (0 === \strpos($key, 'REDIRECT_')) { + $key = \substr($key, 9); + + // We will not overwrite existing variables with the + // prefixed versions, though + if (\array_key_exists($key, $server)) { + continue; + } + } + + if ($value && 0 === \strpos($key, 'HTTP_')) { + $name = \strtr(\strtolower(\substr($key, 5)), '_', '-'); + $headers[$name] = $value; + + continue; + } + + if ($value && 0 === \strpos($key, 'CONTENT_')) { + $name = 'content-'.\strtolower(\substr($key, 8)); + $headers[$name] = $value; + + continue; + } + } + + return $headers; + } + + private function getMethodFromEnv(array $environment): string + { + if (false === isset($environment['REQUEST_METHOD'])) { + throw new \InvalidArgumentException('Cannot determine HTTP method'); + } + + return $environment['REQUEST_METHOD']; + } + + private function getUriFromEnvWithHTTP(array $environment): UriInterface + { + $uri = $this->createUriFromArray($environment); + if (empty($uri->getScheme())) { + $uri = $uri->withScheme('http'); + } + + return $uri; + } + + /** + * Return an UploadedFile instance array. + * + * @param array $files A array which respect $_FILES structure + * + * @return UploadedFileInterface[] + * + * @throws \InvalidArgumentException for unrecognized values + */ + private function normalizeFiles(array $files): array + { + $normalized = []; + + foreach ($files as $key => $value) { + if ($value instanceof UploadedFileInterface) { + $normalized[$key] = $value; + } elseif (\is_array($value) && isset($value['tmp_name'])) { + $normalized[$key] = $this->createUploadedFileFromSpec($value); + } elseif (\is_array($value)) { + $normalized[$key] = $this->normalizeFiles($value); + } else { + throw new \InvalidArgumentException('Invalid value in files specification'); + } + } + + return $normalized; + } + + /** + * Create and return an UploadedFile instance from a $_FILES specification. + * + * If the specification represents an array of values, this method will + * delegate to normalizeNestedFileSpec() and return that return value. + * + * @param array $value $_FILES struct + * + * @return array|UploadedFileInterface + */ + private function createUploadedFileFromSpec(array $value) + { + if (\is_array($value['tmp_name'])) { + return $this->normalizeNestedFileSpec($value); + } + + try { + $stream = $this->streamFactory->createStreamFromFile($value['tmp_name']); + } catch (\RuntimeException $e) { + $stream = $this->streamFactory->createStream(); + } + + return $this->uploadedFileFactory->createUploadedFile( + $stream, + (int) $value['size'], + (int) $value['error'], + $value['name'], + $value['type'] + ); + } + + /** + * Normalize an array of file specifications. + * + * Loops through all nested files and returns a normalized array of + * UploadedFileInterface instances. + * + * @param array $files + * + * @return UploadedFileInterface[] + */ + private function normalizeNestedFileSpec(array $files = []): array + { + $normalizedFiles = []; + + foreach (\array_keys($files['tmp_name']) as $key) { + $spec = [ + 'tmp_name' => $files['tmp_name'][$key], + 'size' => $files['size'][$key], + 'error' => $files['error'][$key], + 'name' => $files['name'][$key], + 'type' => $files['type'][$key], + ]; + $normalizedFiles[$key] = $this->createUploadedFileFromSpec($spec); + } + + return $normalizedFiles; + } + + /** + * Create a new uri from server variable. + * + * @param array $server typically $_SERVER or similar structure + */ + private function createUriFromArray(array $server): UriInterface + { + $uri = $this->uriFactory->createUri(''); + + if (isset($server['HTTP_X_FORWARDED_PROTO'])) { + $uri = $uri->withScheme($server['HTTP_X_FORWARDED_PROTO']); + } else { + if (isset($server['REQUEST_SCHEME'])) { + $uri = $uri->withScheme($server['REQUEST_SCHEME']); + } elseif (isset($server['HTTPS'])) { + $uri = $uri->withScheme('on' === $server['HTTPS'] ? 'https' : 'http'); + } + + if (isset($server['SERVER_PORT'])) { + $uri = $uri->withPort($server['SERVER_PORT']); + } + } + + if (isset($server['HTTP_HOST'])) { + if (1 === \preg_match('/^(.+)\:(\d+)$/', $server['HTTP_HOST'], $matches)) { + $uri = $uri->withHost($matches[1])->withPort($matches[2]); + } else { + $uri = $uri->withHost($server['HTTP_HOST']); + } + } elseif (isset($server['SERVER_NAME'])) { + $uri = $uri->withHost($server['SERVER_NAME']); + } + + if (isset($server['REQUEST_URI'])) { + $uri = $uri->withPath(\current(\explode('?', $server['REQUEST_URI']))); + } + + if (isset($server['QUERY_STRING'])) { + $uri = $uri->withQuery($server['QUERY_STRING']); + } + + return $uri; + } + } +} + +// file: vendor/nyholm/psr7-server/src/ServerRequestCreatorInterface.php +namespace Nyholm\Psr7Server { + + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Message\StreamInterface; + + /** + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + interface ServerRequestCreatorInterface + { + /** + * Create a new server request from the current environment variables. + * Defaults to a GET request to minimise the risk of an \InvalidArgumentException. + * Includes the current request headers as supplied by the server through `getallheaders()`. + * If `getallheaders()` is unavailable on the current server it will fallback to its own `getHeadersFromServer()` method. + * Defaults to php://input for the request body. + * + * @throws \InvalidArgumentException if no valid method or URI can be determined + */ + public function fromGlobals(): ServerRequestInterface; + + /** + * Create a new server request from a set of arrays. + * + * @param array $server typically $_SERVER or similar structure + * @param array $headers typically the output of getallheaders() or similar structure + * @param array $cookie typically $_COOKIE or similar structure + * @param array $get typically $_GET or similar structure + * @param array $post typically $_POST or similar structure + * @param array $files typically $_FILES or similar structure + * @param StreamInterface|resource|string|null $body Typically stdIn + * + * @throws \InvalidArgumentException if no valid method or URI can be determined + */ + public function fromArrays( + array $server, + array $headers = [], + array $cookie = [], + array $get = [], + array $post = [], + array $files = [], + $body = null + ): ServerRequestInterface; + + /** + * Get parsed headers from ($_SERVER) array. + * + * @param array $server typically $_SERVER or similar structure + * + * @return array + */ + public static function getHeadersFromServer(array $server): array; + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/Cache.php +namespace Tqdev\PhpCrudApi\Cache { + + interface Cache + { + public function set(string $key, string $value, int $ttl = 0): bool; + public function get(string $key): string; + public function clear(): bool; + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/CacheFactory.php +namespace Tqdev\PhpCrudApi\Cache { + + class CacheFactory + { + public static function create(string $type, string $prefix, string $config): Cache + { + switch ($type) { + case 'TempFile': + $cache = new TempFileCache($prefix, $config); + break; + case 'Redis': + $cache = new RedisCache($prefix, $config); + break; + case 'Memcache': + $cache = new MemcacheCache($prefix, $config); + break; + case 'Memcached': + $cache = new MemcachedCache($prefix, $config); + break; + default: + $cache = new NoCache(); + } + return $cache; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/MemcacheCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class MemcacheCache implements Cache + { + protected $prefix; + protected $memcache; + + public function __construct(string $prefix, string $config) + { + $this->prefix = $prefix; + if ($config == '') { + $address = 'localhost'; + $port = 11211; + } elseif (strpos($config, ':') === false) { + $address = $config; + $port = 11211; + } else { + list($address, $port) = explode(':', $config); + } + $this->memcache = $this->create(); + $this->memcache->addServer($address, $port); + } + + protected function create() /*: \Memcache*/ + { + return new \Memcache(); + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return $this->memcache->set($this->prefix . $key, $value, 0, $ttl); + } + + public function get(string $key): string + { + return $this->memcache->get($this->prefix . $key) ?: ''; + } + + public function clear(): bool + { + return $this->memcache->flush(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/MemcachedCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class MemcachedCache extends MemcacheCache + { + protected function create() /*: \Memcached*/ + { + return new \Memcached(); + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return $this->memcache->set($this->prefix . $key, $value, $ttl); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/NoCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class NoCache implements Cache + { + public function __construct() + { + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return true; + } + + public function get(string $key): string + { + return ''; + } + + public function clear(): bool + { + return true; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/RedisCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class RedisCache implements Cache + { + protected $prefix; + protected $redis; + + public function __construct(string $prefix, string $config) + { + $this->prefix = $prefix; + if ($config == '') { + $config = '127.0.0.1'; + } + $params = explode(':', $config, 6); + if (isset($params[3])) { + $params[3] = null; + } + $this->redis = new \Redis(); + call_user_func_array(array($this->redis, 'pconnect'), $params); + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return $this->redis->set($this->prefix . $key, $value, $ttl); + } + + public function get(string $key): string + { + return $this->redis->get($this->prefix . $key) ?: ''; + } + + public function clear(): bool + { + return $this->redis->flushDb(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/TempFileCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class TempFileCache implements Cache + { + const SUFFIX = 'cache'; + + private $path; + private $segments; + + public function __construct(string $prefix, string $config) + { + $this->segments = []; + $s = DIRECTORY_SEPARATOR; + $ps = PATH_SEPARATOR; + if ($config == '') { + $this->path = sys_get_temp_dir() . $s . $prefix . self::SUFFIX; + } elseif (strpos($config, $ps) === false) { + $this->path = $config; + } else { + list($path, $segments) = explode($ps, $config); + $this->path = $path; + $this->segments = explode(',', $segments); + } + if (file_exists($this->path) && is_dir($this->path)) { + $this->clean($this->path, array_filter($this->segments), strlen(md5('')), false); + } + } + + private function getFileName(string $key): string + { + $s = DIRECTORY_SEPARATOR; + $md5 = md5($key); + $filename = rtrim($this->path, $s) . $s; + $i = 0; + foreach ($this->segments as $segment) { + $filename .= substr($md5, $i, $segment) . $s; + $i += $segment; + } + $filename .= substr($md5, $i); + return $filename; + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + $filename = $this->getFileName($key); + $dirname = dirname($filename); + if (!file_exists($dirname)) { + if (!mkdir($dirname, 0755, true)) { + return false; + } + } + $string = $ttl . '|' . $value; + return $this->filePutContents($filename, $string) !== false; + } + + private function filePutContents($filename, $string) + { + return file_put_contents($filename, $string, LOCK_EX); + } + + private function fileGetContents($filename) + { + $file = fopen($filename, 'rb'); + if ($file === false) { + return false; + } + $lock = flock($file, LOCK_SH); + if (!$lock) { + fclose($file); + return false; + } + $string = ''; + while (!feof($file)) { + $string .= fread($file, 8192); + } + flock($file, LOCK_UN); + fclose($file); + return $string; + } + + private function getString($filename): string + { + $data = $this->fileGetContents($filename); + if ($data === false) { + return ''; + } + if (strpos($data, '|') === false) { + return ''; + } + list($ttl, $string) = explode('|', $data, 2); + if ($ttl > 0 && time() - filemtime($filename) > $ttl) { + return ''; + } + return $string; + } + + public function get(string $key): string + { + $filename = $this->getFileName($key); + if (!file_exists($filename)) { + return ''; + } + $string = $this->getString($filename); + if ($string == null) { + return ''; + } + return $string; + } + + private function clean(string $path, array $segments, int $len, bool $all) /*: void*/ + { + $entries = scandir($path); + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $filename = $path . DIRECTORY_SEPARATOR . $entry; + if (count($segments) == 0) { + if (strlen($entry) != $len) { + continue; + } + if (file_exists($filename) && is_file($filename)) { + if ($all || $this->getString($filename) == null) { + @unlink($filename); + } + } + } else { + if (strlen($entry) != $segments[0]) { + continue; + } + if (file_exists($filename) && is_dir($filename)) { + $this->clean($filename, array_slice($segments, 1), $len - $segments[0], $all); + @rmdir($filename); + } + } + } + } + + public function clear(): bool + { + if (!file_exists($this->path) || !is_dir($this->path)) { + return false; + } + $this->clean($this->path, array_filter($this->segments), strlen(md5('')), true); + return true; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php +namespace Tqdev\PhpCrudApi\Column\Reflection { + + use Tqdev\PhpCrudApi\Database\GenericReflection; + + class ReflectedColumn implements \JsonSerializable + { + const DEFAULT_LENGTH = 255; + const DEFAULT_PRECISION = 19; + const DEFAULT_SCALE = 4; + + private $name; + private $type; + private $length; + private $precision; + private $scale; + private $nullable; + private $pk; + private $fk; + + public function __construct(string $name, string $type, int $length, int $precision, int $scale, bool $nullable, bool $pk, string $fk) + { + $this->name = $name; + $this->type = $type; + $this->length = $length; + $this->precision = $precision; + $this->scale = $scale; + $this->nullable = $nullable; + $this->pk = $pk; + $this->fk = $fk; + $this->sanitize(); + } + + private static function parseColumnType(string $columnType, int &$length, int &$precision, int &$scale) /*: void*/ + { + if (!$columnType) { + return; + } + $pos = strpos($columnType, '('); + if ($pos) { + $dataSize = rtrim(substr($columnType, $pos + 1), ')'); + if ($length) { + $length = (int) $dataSize; + } else { + $pos = strpos($dataSize, ','); + if ($pos) { + $precision = (int) substr($dataSize, 0, $pos); + $scale = (int) substr($dataSize, $pos + 1); + } else { + $precision = (int) $dataSize; + $scale = 0; + } + } + } + } + + private static function getDataSize(int $length, int $precision, int $scale): string + { + $dataSize = ''; + if ($length) { + $dataSize = $length; + } elseif ($precision) { + if ($scale) { + $dataSize = $precision . ',' . $scale; + } else { + $dataSize = $precision; + } + } + return $dataSize; + } + + public static function fromReflection(GenericReflection $reflection, array $columnResult): ReflectedColumn + { + $name = $columnResult['COLUMN_NAME']; + $dataType = $columnResult['DATA_TYPE']; + $length = (int) $columnResult['CHARACTER_MAXIMUM_LENGTH']; + $precision = (int) $columnResult['NUMERIC_PRECISION']; + $scale = (int) $columnResult['NUMERIC_SCALE']; + $columnType = $columnResult['COLUMN_TYPE']; + self::parseColumnType($columnType, $length, $precision, $scale); + $dataSize = self::getDataSize($length, $precision, $scale); + $type = $reflection->toJdbcType($dataType, $dataSize); + $nullable = in_array(strtoupper($columnResult['IS_NULLABLE']), ['TRUE', 'YES', 'T', 'Y', '1']); + $pk = false; + $fk = ''; + return new ReflectedColumn($name, $type, $length, $precision, $scale, $nullable, $pk, $fk); + } + + public static function fromJson(/* object */$json): ReflectedColumn + { + $name = $json->name; + $type = $json->type; + $length = isset($json->length) ? (int) $json->length : 0; + $precision = isset($json->precision) ? (int) $json->precision : 0; + $scale = isset($json->scale) ? (int) $json->scale : 0; + $nullable = isset($json->nullable) ? (bool) $json->nullable : false; + $pk = isset($json->pk) ? (bool) $json->pk : false; + $fk = isset($json->fk) ? $json->fk : ''; + return new ReflectedColumn($name, $type, $length, $precision, $scale, $nullable, $pk, $fk); + } + + private function sanitize() + { + $this->length = $this->hasLength() ? $this->getLength() : 0; + $this->precision = $this->hasPrecision() ? $this->getPrecision() : 0; + $this->scale = $this->hasScale() ? $this->getScale() : 0; + } + + public function getName(): string + { + return $this->name; + } + + public function getNullable(): bool + { + return $this->nullable; + } + + public function getType(): string + { + return $this->type; + } + + public function getLength(): int + { + return $this->length ?: self::DEFAULT_LENGTH; + } + + public function getPrecision(): int + { + return $this->precision ?: self::DEFAULT_PRECISION; + } + + public function getScale(): int + { + return $this->scale ?: self::DEFAULT_SCALE; + } + + public function hasLength(): bool + { + return in_array($this->type, ['varchar', 'varbinary']); + } + + public function hasPrecision(): bool + { + return $this->type == 'decimal'; + } + + public function hasScale(): bool + { + return $this->type == 'decimal'; + } + + public function isBinary(): bool + { + return in_array($this->type, ['blob', 'varbinary']); + } + + public function isBoolean(): bool + { + return $this->type == 'boolean'; + } + + public function isGeometry(): bool + { + return $this->type == 'geometry'; + } + + public function isInteger(): bool + { + return in_array($this->type, ['integer', 'bigint', 'smallint', 'tinyint']); + } + + public function setPk($value) /*: void*/ + { + $this->pk = $value; + } + + public function getPk(): bool + { + return $this->pk; + } + + public function setFk($value) /*: void*/ + { + $this->fk = $value; + } + + public function getFk(): string + { + return $this->fk; + } + + public function serialize() + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'length' => $this->length, + 'precision' => $this->precision, + 'scale' => $this->scale, + 'nullable' => $this->nullable, + 'pk' => $this->pk, + 'fk' => $this->fk, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedDatabase.php +namespace Tqdev\PhpCrudApi\Column\Reflection { + + use Tqdev\PhpCrudApi\Database\GenericReflection; + + class ReflectedDatabase implements \JsonSerializable + { + private $tableTypes; + + public function __construct(array $tableTypes) + { + $this->tableTypes = $tableTypes; + } + + public static function fromReflection(GenericReflection $reflection): ReflectedDatabase + { + $tableTypes = []; + foreach ($reflection->getTables() as $table) { + $tableName = $table['TABLE_NAME']; + $tableType = $table['TABLE_TYPE']; + if (in_array($tableName, $reflection->getIgnoredTables())) { + continue; + } + $tableTypes[$tableName] = $tableType; + } + return new ReflectedDatabase($tableTypes); + } + + public static function fromJson(/* object */$json): ReflectedDatabase + { + $tableTypes = (array) $json->tables; + return new ReflectedDatabase($tableTypes); + } + + public function hasTable(string $tableName): bool + { + return isset($this->tableTypes[$tableName]); + } + + public function getType(string $tableName): string + { + return isset($this->tableTypes[$tableName]) ? $this->tableTypes[$tableName] : ''; + } + + public function getTableNames(): array + { + return array_keys($this->tableTypes); + } + + public function removeTable(string $tableName): bool + { + if (!isset($this->tableTypes[$tableName])) { + return false; + } + unset($this->tableTypes[$tableName]); + return true; + } + + public function serialize() + { + return [ + 'tables' => $this->tableTypes, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedTable.php +namespace Tqdev\PhpCrudApi\Column\Reflection { + + use Tqdev\PhpCrudApi\Database\GenericReflection; + + class ReflectedTable implements \JsonSerializable + { + private $name; + private $type; + private $columns; + private $pk; + private $fks; + + public function __construct(string $name, string $type, array $columns) + { + $this->name = $name; + $this->type = $type; + // set columns + $this->columns = []; + foreach ($columns as $column) { + $columnName = $column->getName(); + $this->columns[$columnName] = $column; + } + // set primary key + $this->pk = null; + foreach ($columns as $column) { + if ($column->getPk() == true) { + $this->pk = $column; + } + } + // set foreign keys + $this->fks = []; + foreach ($columns as $column) { + $columnName = $column->getName(); + $referencedTableName = $column->getFk(); + if ($referencedTableName != '') { + $this->fks[$columnName] = $referencedTableName; + } + } + } + + public static function fromReflection(GenericReflection $reflection, string $name, string $type): ReflectedTable + { + // set columns + $columns = []; + foreach ($reflection->getTableColumns($name, $type) as $tableColumn) { + $column = ReflectedColumn::fromReflection($reflection, $tableColumn); + $columns[$column->getName()] = $column; + } + // set primary key + $columnName = false; + if ($type == 'view') { + $columnName = 'id'; + } else { + $columnNames = $reflection->getTablePrimaryKeys($name); + if (count($columnNames) == 1) { + $columnName = $columnNames[0]; + } + } + if ($columnName && isset($columns[$columnName])) { + $pk = $columns[$columnName]; + $pk->setPk(true); + } + // set foreign keys + if ($type == 'view') { + $tables = $reflection->getTables(); + foreach ($columns as $columnName => $column) { + if (substr($columnName, -3) == '_id') { + foreach ($tables as $table) { + $tableName = $table['TABLE_NAME']; + $suffix = $tableName . '_id'; + if (substr($columnName, -1 * strlen($suffix)) == $suffix) { + $column->setFk($tableName); + } + } + } + } + } else { + $fks = $reflection->getTableForeignKeys($name); + foreach ($fks as $columnName => $table) { + $columns[$columnName]->setFk($table); + } + } + return new ReflectedTable($name, $type, array_values($columns)); + } + + public static function fromJson( /* object */$json): ReflectedTable + { + $name = $json->name; + $type = isset($json->type) ? $json->type : 'table'; + $columns = []; + if (isset($json->columns) && is_array($json->columns)) { + foreach ($json->columns as $column) { + $columns[] = ReflectedColumn::fromJson($column); + } + } + return new ReflectedTable($name, $type, $columns); + } + + public function hasColumn(string $columnName): bool + { + return isset($this->columns[$columnName]); + } + + public function hasPk(): bool + { + return $this->pk != null; + } + + public function getPk() /*: ?ReflectedColumn */ + { + return $this->pk; + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): string + { + return $this->type; + } + + public function getColumnNames(): array + { + return array_keys($this->columns); + } + + public function getColumn($columnName): ReflectedColumn + { + return $this->columns[$columnName]; + } + + public function getFksTo(string $tableName): array + { + $columns = array(); + foreach ($this->fks as $columnName => $referencedTableName) { + if ($tableName == $referencedTableName && !is_null($this->columns[$columnName])) { + $columns[] = $this->columns[$columnName]; + } + } + return $columns; + } + + public function removeColumn(string $columnName): bool + { + if (!isset($this->columns[$columnName])) { + return false; + } + unset($this->columns[$columnName]); + return true; + } + + public function serialize() + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'columns' => array_values($this->columns), + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/DefinitionService.php +namespace Tqdev\PhpCrudApi\Column { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Database\GenericDB; + + class DefinitionService + { + private $db; + private $reflection; + + public function __construct(GenericDB $db, ReflectionService $reflection) + { + $this->db = $db; + $this->reflection = $reflection; + } + + public function updateTable(string $tableName, /* object */ $changes): bool + { + $table = $this->reflection->getTable($tableName); + $newTable = ReflectedTable::fromJson((object) array_merge((array) $table->jsonSerialize(), (array) $changes)); + if ($table->getName() != $newTable->getName()) { + if (!$this->db->definition()->renameTable($table->getName(), $newTable->getName())) { + return false; + } + } + return true; + } + + public function updateColumn(string $tableName, string $columnName, /* object */ $changes): bool + { + $table = $this->reflection->getTable($tableName); + $column = $table->getColumn($columnName); + + // remove constraints on other column + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + if ($newColumn->getPk() != $column->getPk() && $table->hasPk()) { + $oldColumn = $table->getPk(); + if ($oldColumn->getName() != $columnName) { + $oldColumn->setPk(false); + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $oldColumn->getName(), $oldColumn)) { + return false; + } + } + } + + // remove constraints + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), ['pk' => false, 'fk' => false])); + if ($newColumn->getPk() != $column->getPk() && !$newColumn->getPk()) { + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getFk() != $column->getFk() && !$newColumn->getFk()) { + if (!$this->db->definition()->removeColumnForeignKey($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + + // name and type + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + $newColumn->setPk(false); + $newColumn->setFk(''); + if ($newColumn->getName() != $column->getName()) { + if (!$this->db->definition()->renameColumn($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + if ( + $newColumn->getType() != $column->getType() || + $newColumn->getLength() != $column->getLength() || + $newColumn->getPrecision() != $column->getPrecision() || + $newColumn->getScale() != $column->getScale() + ) { + if (!$this->db->definition()->retypeColumn($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getNullable() != $column->getNullable()) { + if (!$this->db->definition()->setColumnNullable($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + + // add constraints + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + if ($newColumn->getFk()) { + if (!$this->db->definition()->addColumnForeignKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getPk()) { + if (!$this->db->definition()->addColumnPrimaryKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + return true; + } + + public function addTable(/* object */$definition) + { + $newTable = ReflectedTable::fromJson($definition); + if (!$this->db->definition()->addTable($newTable)) { + return false; + } + return true; + } + + public function addColumn(string $tableName, /* object */ $definition) + { + $newColumn = ReflectedColumn::fromJson($definition); + if (!$this->db->definition()->addColumn($tableName, $newColumn)) { + return false; + } + if ($newColumn->getFk()) { + if (!$this->db->definition()->addColumnForeignKey($tableName, $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getPk()) { + if (!$this->db->definition()->addColumnPrimaryKey($tableName, $newColumn->getName(), $newColumn)) { + return false; + } + } + return true; + } + + public function removeTable(string $tableName) + { + if (!$this->db->definition()->removeTable($tableName)) { + return false; + } + return true; + } + + public function removeColumn(string $tableName, string $columnName) + { + $table = $this->reflection->getTable($tableName); + $newColumn = $table->getColumn($columnName); + if ($newColumn->getPk()) { + $newColumn->setPk(false); + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getFk()) { + $newColumn->setFk(""); + if (!$this->db->definition()->removeColumnForeignKey($tableName, $columnName, $newColumn)) { + return false; + } + } + if (!$this->db->definition()->removeColumn($tableName, $columnName)) { + return false; + } + return true; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/ReflectionService.php +namespace Tqdev\PhpCrudApi\Column { + + use Tqdev\PhpCrudApi\Cache\Cache; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedDatabase; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Database\GenericDB; + + class ReflectionService + { + private $db; + private $cache; + private $ttl; + private $database; + private $tables; + + public function __construct(GenericDB $db, Cache $cache, int $ttl) + { + $this->db = $db; + $this->cache = $cache; + $this->ttl = $ttl; + $this->database = null; + $this->tables = []; + } + + private function database(): ReflectedDatabase + { + if ($this->database) { + return $this->database; + } + $this->database = $this->loadDatabase(true); + return $this->database; + } + + private function loadDatabase(bool $useCache): ReflectedDatabase + { + $key = sprintf('%s-ReflectedDatabase', $this->db->getCacheKey()); + $data = $useCache ? $this->cache->get($key) : ''; + if ($data != '') { + $database = ReflectedDatabase::fromJson(json_decode(gzuncompress($data))); + } else { + $database = ReflectedDatabase::fromReflection($this->db->reflection()); + $data = gzcompress(json_encode($database, JSON_UNESCAPED_UNICODE)); + $this->cache->set($key, $data, $this->ttl); + } + return $database; + } + + private function loadTable(string $tableName, bool $useCache): ReflectedTable + { + $key = sprintf('%s-ReflectedTable(%s)', $this->db->getCacheKey(), $tableName); + $data = $useCache ? $this->cache->get($key) : ''; + if ($data != '') { + $table = ReflectedTable::fromJson(json_decode(gzuncompress($data))); + } else { + $tableType = $this->database()->getType($tableName); + $table = ReflectedTable::fromReflection($this->db->reflection(), $tableName, $tableType); + $data = gzcompress(json_encode($table, JSON_UNESCAPED_UNICODE)); + $this->cache->set($key, $data, $this->ttl); + } + return $table; + } + + public function refreshTables() + { + $this->database = $this->loadDatabase(false); + } + + public function refreshTable(string $tableName) + { + $this->tables[$tableName] = $this->loadTable($tableName, false); + } + + public function hasTable(string $tableName): bool + { + return $this->database()->hasTable($tableName); + } + + public function getType(string $tableName): string + { + return $this->database()->getType($tableName); + } + + public function getTable(string $tableName): ReflectedTable + { + if (!isset($this->tables[$tableName])) { + $this->tables[$tableName] = $this->loadTable($tableName, true); + } + return $this->tables[$tableName]; + } + + public function getTableNames(): array + { + return $this->database()->getTableNames(); + } + + public function removeTable(string $tableName): bool + { + unset($this->tables[$tableName]); + return $this->database()->removeTable($tableName); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/CacheController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Cache\Cache; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + + class CacheController + { + private $cache; + private $responder; + + public function __construct(Router $router, Responder $responder, Cache $cache) + { + $router->register('GET', '/cache/clear', array($this, 'clear')); + $this->cache = $cache; + $this->responder = $responder; + } + + public function clear(ServerRequestInterface $request): ResponseInterface + { + return $this->responder->success($this->cache->clear()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/ColumnController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Column\DefinitionService; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class ColumnController + { + private $responder; + private $reflection; + private $definition; + + public function __construct(Router $router, Responder $responder, ReflectionService $reflection, DefinitionService $definition) + { + $router->register('GET', '/columns', array($this, 'getDatabase')); + $router->register('GET', '/columns/*', array($this, 'getTable')); + $router->register('GET', '/columns/*/*', array($this, 'getColumn')); + $router->register('PUT', '/columns/*', array($this, 'updateTable')); + $router->register('PUT', '/columns/*/*', array($this, 'updateColumn')); + $router->register('POST', '/columns', array($this, 'addTable')); + $router->register('POST', '/columns/*', array($this, 'addColumn')); + $router->register('DELETE', '/columns/*', array($this, 'removeTable')); + $router->register('DELETE', '/columns/*/*', array($this, 'removeColumn')); + $this->responder = $responder; + $this->reflection = $reflection; + $this->definition = $definition; + } + + public function getDatabase(ServerRequestInterface $request): ResponseInterface + { + $tables = []; + foreach ($this->reflection->getTableNames() as $table) { + $tables[] = $this->reflection->getTable($table); + } + $database = ['tables' => $tables]; + return $this->responder->success($database); + } + + public function getTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + return $this->responder->success($table); + } + + public function getColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $column = $table->getColumn($columnName); + return $this->responder->success($column); + } + + public function updateTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $success = $this->definition->updateTable($tableName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function updateColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $success = $this->definition->updateColumn($tableName, $columnName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } + + public function addTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = $request->getParsedBody()->name; + if ($this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_ALREADY_EXISTS, $tableName); + } + $success = $this->definition->addTable($request->getParsedBody()); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function addColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $columnName = $request->getParsedBody()->name; + $table = $this->reflection->getTable($tableName); + if ($table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_ALREADY_EXISTS, $columnName); + } + $success = $this->definition->addColumn($tableName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } + + public function removeTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $success = $this->definition->removeTable($tableName); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function removeColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $success = $this->definition->removeColumn($tableName, $columnName); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/GeoJsonController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\GeoJson\GeoJsonService; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class GeoJsonController + { + private $service; + private $responder; + + public function __construct(Router $router, Responder $responder, GeoJsonService $service) + { + $router->register('GET', '/geojson/*', array($this, '_list')); + $router->register('GET', '/geojson/*/*', array($this, 'read')); + $this->service = $service; + $this->responder = $responder; + } + + public function _list(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + $params = RequestUtils::getParams($request); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + return $this->responder->success($this->service->_list($table, $params)); + } + + public function read(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + if (strpos($id, ',') !== false) { + $ids = explode(',', $id); + $result = (object) array('type' => 'FeatureCollection', 'features' => array()); + for ($i = 0; $i < count($ids); $i++) { + array_push($result->features, $this->service->read($table, $ids[$i], $params)); + } + return $this->responder->success($result); + } else { + $response = $this->service->read($table, $id, $params); + if ($response === null) { + return $this->responder->error(ErrorCode::RECORD_NOT_FOUND, $id); + } + return $this->responder->success($response); + } + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/JsonResponder.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Tqdev\PhpCrudApi\Record\Document\ErrorDocument; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\ResponseFactory; + + class JsonResponder implements Responder + { + public function error(int $error, string $argument, $details = null): ResponseInterface + { + $errorCode = new ErrorCode($error); + $status = $errorCode->getStatus(); + $document = new ErrorDocument($errorCode, $argument, $details); + return ResponseFactory::fromObject($status, $document); + } + + public function success($result): ResponseInterface + { + return ResponseFactory::fromObject(ResponseFactory::OK, $result); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/OpenApiController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\OpenApi\OpenApiService; + + class OpenApiController + { + private $openApi; + private $responder; + + public function __construct(Router $router, Responder $responder, OpenApiService $openApi) + { + $router->register('GET', '/openapi', array($this, 'openapi')); + $this->openApi = $openApi; + $this->responder = $responder; + } + + public function openapi(ServerRequestInterface $request): ResponseInterface + { + return $this->responder->success($this->openApi->get()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/RecordController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\RecordService; + use Tqdev\PhpCrudApi\RequestUtils; + + class RecordController + { + private $service; + private $responder; + + public function __construct(Router $router, Responder $responder, RecordService $service) + { + $router->register('GET', '/records/*', array($this, '_list')); + $router->register('POST', '/records/*', array($this, 'create')); + $router->register('GET', '/records/*/*', array($this, 'read')); + $router->register('PUT', '/records/*/*', array($this, 'update')); + $router->register('DELETE', '/records/*/*', array($this, 'delete')); + $router->register('PATCH', '/records/*/*', array($this, 'increment')); + $this->service = $service; + $this->responder = $responder; + } + + public function _list(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + $params = RequestUtils::getParams($request); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + return $this->responder->success($this->service->_list($table, $params)); + } + + public function read(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + if (strpos($id, ',') !== false) { + $ids = explode(',', $id); + $result = []; + for ($i = 0; $i < count($ids); $i++) { + array_push($result, $this->service->read($table, $ids[$i], $params)); + } + return $this->responder->success($result); + } else { + $response = $this->service->read($table, $id, $params); + if ($response === null) { + return $this->responder->error(ErrorCode::RECORD_NOT_FOUND, $id); + } + return $this->responder->success($response); + } + } + + public function create(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $params = RequestUtils::getParams($request); + if (is_array($record)) { + $result = array(); + foreach ($record as $r) { + $result[] = $this->service->create($table, $r, $params); + } + return $this->responder->success($result); + } else { + return $this->responder->success($this->service->create($table, $record, $params)); + } + } + + public function update(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $ids = explode(',', $id); + if (is_array($record)) { + if (count($ids) != count($record)) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->update($table, $ids[$i], $record[$i], $params); + } + return $this->responder->success($result); + } else { + if (count($ids) != 1) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + return $this->responder->success($this->service->update($table, $id, $record, $params)); + } + } + + public function delete(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + $ids = explode(',', $id); + if (count($ids) > 1) { + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->delete($table, $ids[$i], $params); + } + return $this->responder->success($result); + } else { + return $this->responder->success($this->service->delete($table, $id, $params)); + } + } + + public function increment(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $params = RequestUtils::getParams($request); + $ids = explode(',', $id); + if (is_array($record)) { + if (count($ids) != count($record)) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->increment($table, $ids[$i], $record[$i], $params); + } + return $this->responder->success($result); + } else { + if (count($ids) != 1) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + return $this->responder->success($this->service->increment($table, $id, $record, $params)); + } + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/Responder.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + + interface Responder + { + public function error(int $error, string $argument, $details = null): ResponseInterface; + + public function success($result): ResponseInterface; + } +} + +// file: src/Tqdev/PhpCrudApi/Database/ColumnConverter.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + + class ColumnConverter + { + private $driver; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + public function convertColumnValue(ReflectedColumn $column): string + { + if ($column->isBoolean()) { + switch ($this->driver) { + case 'mysql': + return "IFNULL(IF(?,TRUE,FALSE),NULL)"; + case 'pgsql': + return "?"; + case 'sqlsrv': + return "?"; + } + } + if ($column->isBinary()) { + switch ($this->driver) { + case 'mysql': + return "FROM_BASE64(?)"; + case 'pgsql': + return "decode(?, 'base64')"; + case 'sqlsrv': + return "CONVERT(XML, ?).value('.','varbinary(max)')"; + } + } + if ($column->isGeometry()) { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ST_GeomFromText(?)"; + case 'sqlsrv': + return "geometry::STGeomFromText(?,0)"; + } + } + return '?'; + } + + public function convertColumnName(ReflectedColumn $column, $value): string + { + if ($column->isBinary()) { + switch ($this->driver) { + case 'mysql': + return "TO_BASE64($value) as $value"; + case 'pgsql': + return "encode($value::bytea, 'base64') as $value"; + case 'sqlsrv': + return "CASE WHEN $value IS NULL THEN NULL ELSE (SELECT CAST($value as varbinary(max)) FOR XML PATH(''), BINARY BASE64) END as $value"; + } + } + if ($column->isGeometry()) { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ST_AsText($value) as $value"; + case 'sqlsrv': + return "REPLACE($value.STAsText(),' (','(') as $value"; + } + } + return $value; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/ColumnsBuilder.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + class ColumnsBuilder + { + private $driver; + private $converter; + + public function __construct(string $driver) + { + $this->driver = $driver; + $this->converter = new ColumnConverter($driver); + } + + public function getOffsetLimit(int $offset, int $limit): string + { + if ($limit < 0 || $offset < 0) { + return ''; + } + switch ($this->driver) { + case 'mysql': + return " LIMIT $offset, $limit"; + case 'pgsql': + return " LIMIT $limit OFFSET $offset"; + case 'sqlsrv': + return " OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY"; + case 'sqlite': + return " LIMIT $limit OFFSET $offset"; + } + } + + private function quoteColumnName(ReflectedColumn $column): string + { + return '"' . $column->getName() . '"'; + } + + public function getOrderBy(ReflectedTable $table, array $columnOrdering): string + { + if (count($columnOrdering) == 0) { + return ''; + } + $results = array(); + foreach ($columnOrdering as $i => list($columnName, $ordering)) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $results[] = $quotedColumnName . ' ' . $ordering; + } + return ' ORDER BY ' . implode(',', $results); + } + + public function getSelect(ReflectedTable $table, array $columnNames): string + { + $results = array(); + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $quotedColumnName = $this->converter->convertColumnName($column, $quotedColumnName); + $results[] = $quotedColumnName; + } + return implode(',', $results); + } + + public function getInsert(ReflectedTable $table, array $columnValues): string + { + $columns = array(); + $values = array(); + foreach ($columnValues as $columnName => $columnValue) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columns[] = $quotedColumnName; + $columnValue = $this->converter->convertColumnValue($column); + $values[] = $columnValue; + } + $columnsSql = '(' . implode(',', $columns) . ')'; + $valuesSql = '(' . implode(',', $values) . ')'; + $outputColumn = $this->quoteColumnName($table->getPk()); + switch ($this->driver) { + case 'mysql': + return "$columnsSql VALUES $valuesSql"; + case 'pgsql': + return "$columnsSql VALUES $valuesSql RETURNING $outputColumn"; + case 'sqlsrv': + return "$columnsSql OUTPUT INSERTED.$outputColumn VALUES $valuesSql"; + case 'sqlite': + return "$columnsSql VALUES $valuesSql"; + } + } + + public function getUpdate(ReflectedTable $table, array $columnValues): string + { + $results = array(); + foreach ($columnValues as $columnName => $columnValue) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columnValue = $this->converter->convertColumnValue($column); + $results[] = $quotedColumnName . '=' . $columnValue; + } + return implode(',', $results); + } + + public function getIncrement(ReflectedTable $table, array $columnValues): string + { + $results = array(); + foreach ($columnValues as $columnName => $columnValue) { + if (!is_numeric($columnValue)) { + continue; + } + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columnValue = $this->converter->convertColumnValue($column); + $results[] = $quotedColumnName . '=' . $quotedColumnName . '+' . $columnValue; + } + return implode(',', $results); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/ConditionsBuilder.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Record\Condition\AndCondition; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\Condition\Condition; + use Tqdev\PhpCrudApi\Record\Condition\NoCondition; + use Tqdev\PhpCrudApi\Record\Condition\NotCondition; + use Tqdev\PhpCrudApi\Record\Condition\OrCondition; + use Tqdev\PhpCrudApi\Record\Condition\SpatialCondition; + + class ConditionsBuilder + { + private $driver; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + private function getConditionSql(Condition $condition, array &$arguments): string + { + if ($condition instanceof AndCondition) { + return $this->getAndConditionSql($condition, $arguments); + } + if ($condition instanceof OrCondition) { + return $this->getOrConditionSql($condition, $arguments); + } + if ($condition instanceof NotCondition) { + return $this->getNotConditionSql($condition, $arguments); + } + if ($condition instanceof SpatialCondition) { + return $this->getSpatialConditionSql($condition, $arguments); + } + if ($condition instanceof ColumnCondition) { + return $this->getColumnConditionSql($condition, $arguments); + } + throw new \Exception('Unknown Condition: ' . get_class($condition)); + } + + private function getAndConditionSql(AndCondition $and, array &$arguments): string + { + $parts = []; + foreach ($and->getConditions() as $condition) { + $parts[] = $this->getConditionSql($condition, $arguments); + } + return '(' . implode(' AND ', $parts) . ')'; + } + + private function getOrConditionSql(OrCondition $or, array &$arguments): string + { + $parts = []; + foreach ($or->getConditions() as $condition) { + $parts[] = $this->getConditionSql($condition, $arguments); + } + return '(' . implode(' OR ', $parts) . ')'; + } + + private function getNotConditionSql(NotCondition $not, array &$arguments): string + { + $condition = $not->getCondition(); + return '(NOT ' . $this->getConditionSql($condition, $arguments) . ')'; + } + + private function quoteColumnName(ReflectedColumn $column): string + { + return '"' . $column->getName() . '"'; + } + + private function escapeLikeValue(string $value): string + { + return addcslashes($value, '%_'); + } + + private function getColumnConditionSql(ColumnCondition $condition, array &$arguments): string + { + $column = $this->quoteColumnName($condition->getColumn()); + $operator = $condition->getOperator(); + $value = $condition->getValue(); + switch ($operator) { + case 'cs': + $sql = "$column LIKE ?"; + $arguments[] = '%' . $this->escapeLikeValue($value) . '%'; + break; + case 'sw': + $sql = "$column LIKE ?"; + $arguments[] = $this->escapeLikeValue($value) . '%'; + break; + case 'ew': + $sql = "$column LIKE ?"; + $arguments[] = '%' . $this->escapeLikeValue($value); + break; + case 'eq': + $sql = "$column = ?"; + $arguments[] = $value; + break; + case 'lt': + $sql = "$column < ?"; + $arguments[] = $value; + break; + case 'le': + $sql = "$column <= ?"; + $arguments[] = $value; + break; + case 'ge': + $sql = "$column >= ?"; + $arguments[] = $value; + break; + case 'gt': + $sql = "$column > ?"; + $arguments[] = $value; + break; + case 'bt': + $parts = explode(',', $value, 2); + $count = count($parts); + if ($count == 2) { + $sql = "($column >= ? AND $column <= ?)"; + $arguments[] = $parts[0]; + $arguments[] = $parts[1]; + } else { + $sql = "FALSE"; + } + break; + case 'in': + $parts = explode(',', $value); + $count = count($parts); + if ($count > 0) { + $qmarks = implode(',', str_split(str_repeat('?', $count))); + $sql = "$column IN ($qmarks)"; + for ($i = 0; $i < $count; $i++) { + $arguments[] = $parts[$i]; + } + } else { + $sql = "FALSE"; + } + break; + case 'is': + $sql = "$column IS NULL"; + break; + } + return $sql; + } + + private function getSpatialFunctionName(string $operator): string + { + switch ($operator) { + case 'co': + return 'ST_Contains'; + case 'cr': + return 'ST_Crosses'; + case 'di': + return 'ST_Disjoint'; + case 'eq': + return 'ST_Equals'; + case 'in': + return 'ST_Intersects'; + case 'ov': + return 'ST_Overlaps'; + case 'to': + return 'ST_Touches'; + case 'wi': + return 'ST_Within'; + case 'ic': + return 'ST_IsClosed'; + case 'is': + return 'ST_IsSimple'; + case 'iv': + return 'ST_IsValid'; + } + } + + private function hasSpatialArgument(string $operator): bool + { + return in_array($operator, ['ic', 'is', 'iv']) ? false : true; + } + + private function getSpatialFunctionCall(string $functionName, string $column, bool $hasArgument): string + { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + $argument = $hasArgument ? 'ST_GeomFromText(?)' : ''; + return "$functionName($column, $argument)=TRUE"; + case 'sqlsrv': + $functionName = str_replace('ST_', 'ST', $functionName); + $argument = $hasArgument ? 'geometry::STGeomFromText(?,0)' : ''; + return "$column.$functionName($argument)=1"; + case 'sqlite': + $argument = $hasArgument ? '?' : '0'; + return "$functionName($column, $argument)=1"; + } + } + + private function getSpatialConditionSql(ColumnCondition $condition, array &$arguments): string + { + $column = $this->quoteColumnName($condition->getColumn()); + $operator = $condition->getOperator(); + $value = $condition->getValue(); + $functionName = $this->getSpatialFunctionName($operator); + $hasArgument = $this->hasSpatialArgument($operator); + $sql = $this->getSpatialFunctionCall($functionName, $column, $hasArgument); + if ($hasArgument) { + $arguments[] = $value; + } + return $sql; + } + + public function getWhereClause(Condition $condition, array &$arguments): string + { + if ($condition instanceof NoCondition) { + return ''; + } + return ' WHERE ' . $this->getConditionSql($condition, $arguments); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/DataConverter.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + class DataConverter + { + private $driver; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + private function convertRecordValue($conversion, $value) + { + $args = explode('|', $conversion); + $type = array_shift($args); + switch ($type) { + case 'boolean': + return $value ? true : false; + case 'integer': + return (int) $value; + case 'float': + return (float) $value; + case 'decimal': + return number_format($value, $args[0], '.', ''); + } + return $value; + } + + private function getRecordValueConversion(ReflectedColumn $column): string + { + if (in_array($this->driver, ['mysql', 'sqlsrv', 'sqlite']) && $column->isBoolean()) { + return 'boolean'; + } + if (in_array($this->driver, ['sqlsrv', 'sqlite']) && in_array($column->getType(), ['integer', 'bigint'])) { + return 'integer'; + } + if (in_array($this->driver, ['sqlite', 'pgsql']) && in_array($column->getType(), ['float', 'double'])) { + return 'float'; + } + if (in_array($this->driver, ['sqlite']) && in_array($column->getType(), ['decimal'])) { + return 'decimal|' . $column->getScale(); + } + return 'none'; + } + + public function convertRecords(ReflectedTable $table, array $columnNames, array &$records) /*: void*/ + { + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $conversion = $this->getRecordValueConversion($column); + if ($conversion != 'none') { + foreach ($records as $i => $record) { + $value = $records[$i][$columnName]; + if ($value === null) { + continue; + } + $records[$i][$columnName] = $this->convertRecordValue($conversion, $value); + } + } + } + } + + private function convertInputValue($conversion, $value) + { + switch ($conversion) { + case 'boolean': + return $value ? 1 : 0; + case 'base64url_to_base64': + return str_pad(strtr($value, '-_', '+/'), ceil(strlen($value) / 4) * 4, '=', STR_PAD_RIGHT); + } + return $value; + } + + private function getInputValueConversion(ReflectedColumn $column): string + { + if ($column->isBoolean()) { + return 'boolean'; + } + if ($column->isBinary()) { + return 'base64url_to_base64'; + } + return 'none'; + } + + public function convertColumnValues(ReflectedTable $table, array &$columnValues) /*: void*/ + { + $columnNames = array_keys($columnValues); + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $conversion = $this->getInputValueConversion($column); + if ($conversion != 'none') { + $value = $columnValues[$columnName]; + if ($value !== null) { + $columnValues[$columnName] = $this->convertInputValue($conversion, $value); + } + } + } + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/GenericDB.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\Condition\Condition; + + class GenericDB + { + private $driver; + private $address; + private $port; + private $database; + private $tables; + private $username; + private $password; + private $pdo; + private $reflection; + private $definition; + private $conditions; + private $columns; + private $converter; + + private function getDsn(): string + { + switch ($this->driver) { + case 'mysql': + return "$this->driver:host=$this->address;port=$this->port;dbname=$this->database;charset=utf8mb4"; + case 'pgsql': + return "$this->driver:host=$this->address port=$this->port dbname=$this->database options='--client_encoding=UTF8'"; + case 'sqlsrv': + return "$this->driver:Server=$this->address,$this->port;Database=$this->database"; + case 'sqlite': + return "$this->driver:$this->address"; + } + } + + private function getCommands(): array + { + switch ($this->driver) { + case 'mysql': + return [ + 'SET SESSION sql_warnings=1;', + 'SET NAMES utf8mb4;', + 'SET SESSION sql_mode = "ANSI,TRADITIONAL";', + ]; + case 'pgsql': + return [ + "SET NAMES 'UTF8';", + ]; + case 'sqlsrv': + return []; + case 'sqlite': + return [ + 'PRAGMA foreign_keys = on;', + ]; + } + } + + private function getOptions(): array + { + $options = array( + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ); + switch ($this->driver) { + case 'mysql': + return $options + [ + \PDO::ATTR_EMULATE_PREPARES => false, + \PDO::MYSQL_ATTR_FOUND_ROWS => true, + \PDO::ATTR_PERSISTENT => true, + ]; + case 'pgsql': + return $options + [ + \PDO::ATTR_EMULATE_PREPARES => false, + \PDO::ATTR_PERSISTENT => true, + ]; + case 'sqlsrv': + return $options + [ + \PDO::SQLSRV_ATTR_DIRECT_QUERY => false, + \PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => true, + ]; + case 'sqlite': + return $options + []; + } + } + + private function initPdo(): bool + { + if ($this->pdo) { + $result = $this->pdo->reconstruct($this->getDsn(), $this->username, $this->password, $this->getOptions()); + } else { + $this->pdo = new LazyPdo($this->getDsn(), $this->username, $this->password, $this->getOptions()); + $result = true; + } + $commands = $this->getCommands(); + foreach ($commands as $command) { + $this->pdo->addInitCommand($command); + } + $this->reflection = new GenericReflection($this->pdo, $this->driver, $this->database, $this->tables); + $this->definition = new GenericDefinition($this->pdo, $this->driver, $this->database, $this->tables); + $this->conditions = new ConditionsBuilder($this->driver); + $this->columns = new ColumnsBuilder($this->driver); + $this->converter = new DataConverter($this->driver); + return $result; + } + + public function __construct(string $driver, string $address, int $port, string $database, array $tables, string $username, string $password) + { + $this->driver = $driver; + $this->address = $address; + $this->port = $port; + $this->database = $database; + $this->tables = $tables; + $this->username = $username; + $this->password = $password; + $this->initPdo(); + } + + public function reconstruct(string $driver, string $address, int $port, string $database, array $tables, string $username, string $password): bool + { + if ($driver) { + $this->driver = $driver; + } + if ($address) { + $this->address = $address; + } + if ($port) { + $this->port = $port; + } + if ($database) { + $this->database = $database; + } + if ($tables) { + $this->tables = $tables; + } + if ($username) { + $this->username = $username; + } + if ($password) { + $this->password = $password; + } + return $this->initPdo(); + } + + public function pdo(): LazyPdo + { + return $this->pdo; + } + + public function reflection(): GenericReflection + { + return $this->reflection; + } + + public function definition(): GenericDefinition + { + return $this->definition; + } + + private function addMiddlewareConditions(string $tableName, Condition $condition): Condition + { + $condition1 = VariableStore::get("authorization.conditions.$tableName"); + if ($condition1) { + $condition = $condition->_and($condition1); + } + $condition2 = VariableStore::get("multiTenancy.conditions.$tableName"); + if ($condition2) { + $condition = $condition->_and($condition2); + } + return $condition; + } + + public function createSingle(ReflectedTable $table, array $columnValues) /*: ?String*/ + { + $this->converter->convertColumnValues($table, $columnValues); + $insertColumns = $this->columns->getInsert($table, $columnValues); + $tableName = $table->getName(); + $pkName = $table->getPk()->getName(); + $parameters = array_values($columnValues); + $sql = 'INSERT INTO "' . $tableName . '" ' . $insertColumns; + $stmt = $this->query($sql, $parameters); + // return primary key value if specified in the input + if (isset($columnValues[$pkName])) { + return $columnValues[$pkName]; + } + // work around missing "returning" or "output" in mysql + switch ($this->driver) { + case 'mysql': + $stmt = $this->query('SELECT LAST_INSERT_ID()', []); + break; + case 'sqlite': + $stmt = $this->query('SELECT LAST_INSERT_ROWID()', []); + break; + } + $pkValue = $stmt->fetchColumn(0); + if ($this->driver == 'sqlsrv' && $table->getPk()->getType() == 'bigint') { + return (int) $pkValue; + } + if ($this->driver == 'sqlite' && in_array($table->getPk()->getType(), ['integer', 'bigint'])) { + return (int) $pkValue; + } + return $pkValue; + } + + public function selectSingle(ReflectedTable $table, array $columnNames, string $id) /*: ?array*/ + { + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + $record = $stmt->fetch() ?: null; + if ($record === null) { + return null; + } + $records = array($record); + $this->converter->convertRecords($table, $columnNames, $records); + return $records[0]; + } + + public function selectMultiple(ReflectedTable $table, array $columnNames, array $ids): array + { + if (count($ids) == 0) { + return []; + } + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'in', implode(',', $ids)); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + $records = $stmt->fetchAll(); + $this->converter->convertRecords($table, $columnNames, $records); + return $records; + } + + public function selectCount(ReflectedTable $table, Condition $condition): int + { + $tableName = $table->getName(); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT COUNT(*) FROM "' . $tableName . '"' . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->fetchColumn(0); + } + + public function selectAll(ReflectedTable $table, array $columnNames, Condition $condition, array $columnOrdering, int $offset, int $limit): array + { + if ($limit == 0) { + return array(); + } + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $orderBy = $this->columns->getOrderBy($table, $columnOrdering); + $offsetLimit = $this->columns->getOffsetLimit($offset, $limit); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '"' . $whereClause . $orderBy . $offsetLimit; + $stmt = $this->query($sql, $parameters); + $records = $stmt->fetchAll(); + $this->converter->convertRecords($table, $columnNames, $records); + return $records; + } + + public function updateSingle(ReflectedTable $table, array $columnValues, string $id) + { + if (count($columnValues) == 0) { + return 0; + } + $this->converter->convertColumnValues($table, $columnValues); + $updateColumns = $this->columns->getUpdate($table, $columnValues); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array_values($columnValues); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'UPDATE "' . $tableName . '" SET ' . $updateColumns . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + public function deleteSingle(ReflectedTable $table, string $id) + { + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'DELETE FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + public function incrementSingle(ReflectedTable $table, array $columnValues, string $id) + { + if (count($columnValues) == 0) { + return 0; + } + $this->converter->convertColumnValues($table, $columnValues); + $updateColumns = $this->columns->getIncrement($table, $columnValues); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array_values($columnValues); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'UPDATE "' . $tableName . '" SET ' . $updateColumns . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + private function query(string $sql, array $parameters): \PDOStatement + { + $stmt = $this->pdo->prepare($sql); + //echo "- $sql -- " . json_encode($parameters, JSON_UNESCAPED_UNICODE) . "\n"; + $stmt->execute($parameters); + return $stmt; + } + + public function getCacheKey(): string + { + return md5(json_encode([ + $this->driver, + $this->address, + $this->port, + $this->database, + $this->tables, + $this->username + ])); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/GenericDefinition.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Database\LazyPdo; + + class GenericDefinition + { + private $pdo; + private $driver; + private $database; + private $typeConverter; + private $reflection; + + public function __construct(LazyPdo $pdo, string $driver, string $database, array $tables) + { + $this->pdo = $pdo; + $this->driver = $driver; + $this->database = $database; + $this->typeConverter = new TypeConverter($driver); + $this->reflection = new GenericReflection($pdo, $driver, $database, $tables); + } + + private function quote(string $identifier): string + { + return '"' . str_replace('"', '', $identifier) . '"'; + } + + public function getColumnType(ReflectedColumn $column, bool $update): string + { + if ($this->driver == 'pgsql' && !$update && $column->getPk() && $this->canAutoIncrement($column)) { + return 'serial'; + } + $type = $this->typeConverter->fromJdbc($column->getType()); + if ($column->hasPrecision() && $column->hasScale()) { + $size = '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; + } elseif ($column->hasPrecision()) { + $size = '(' . $column->getPrecision() . ')'; + } elseif ($column->hasLength()) { + $size = '(' . $column->getLength() . ')'; + } else { + $size = ''; + } + $null = $this->getColumnNullType($column, $update); + $auto = $this->getColumnAutoIncrement($column, $update); + return $type . $size . $null . $auto; + } + + private function getPrimaryKey(string $tableName): string + { + $pks = $this->reflection->getTablePrimaryKeys($tableName); + if (count($pks) == 1) { + return $pks[0]; + } + return ""; + } + + private function canAutoIncrement(ReflectedColumn $column): bool + { + return in_array($column->getType(), ['integer', 'bigint']); + } + + private function getColumnAutoIncrement(ReflectedColumn $column, bool $update): string + { + if (!$this->canAutoIncrement($column)) { + return ''; + } + switch ($this->driver) { + case 'mysql': + return $column->getPk() ? ' AUTO_INCREMENT' : ''; + case 'pgsql': + case 'sqlsrv': + return $column->getPk() ? ' IDENTITY(1,1)' : ''; + case 'sqlite': + return $column->getPk() ? ' AUTOINCREMENT' : ''; + } + } + + private function getColumnNullType(ReflectedColumn $column, bool $update): string + { + if ($this->driver == 'pgsql' && $update) { + return ''; + } + return $column->getNullable() ? ' NULL' : ' NOT NULL'; + } + + private function getTableRenameSQL(string $tableName, string $newTableName): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($newTableName); + + switch ($this->driver) { + case 'mysql': + return "RENAME TABLE $p1 TO $p2"; + case 'pgsql': + return "ALTER TABLE $p1 RENAME TO $p2"; + case 'sqlsrv': + return "EXEC sp_rename $p1, $p2"; + case 'sqlite': + return "ALTER TABLE $p1 RENAME TO $p2"; + } + } + + private function getColumnRenameSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + + switch ($this->driver) { + case 'mysql': + $p4 = $this->getColumnType($newColumn, true); + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + return "ALTER TABLE $p1 RENAME COLUMN $p2 TO $p3"; + case 'sqlsrv': + $p4 = $this->quote($tableName . '.' . $columnName); + return "EXEC sp_rename $p4, $p3, 'COLUMN'"; + case 'sqlite': + return "ALTER TABLE $p1 RENAME COLUMN $p2 TO $p3"; + } + } + + private function getColumnRetypeSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + return "ALTER TABLE $p1 ALTER COLUMN $p3 TYPE $p4"; + case 'sqlsrv': + return "ALTER TABLE $p1 ALTER COLUMN $p3 $p4"; + } + } + + private function getSetColumnNullableSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + $p5 = $newColumn->getNullable() ? 'DROP NOT NULL' : 'SET NOT NULL'; + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p5"; + case 'sqlsrv': + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p4"; + } + } + + private function getSetColumnPkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_pkey'); + + switch ($this->driver) { + case 'mysql': + $p4 = $newColumn->getPk() ? "ADD PRIMARY KEY ($p2)" : 'DROP PRIMARY KEY'; + return "ALTER TABLE $p1 $p4"; + case 'pgsql': + case 'sqlsrv': + $p4 = $newColumn->getPk() ? "ADD CONSTRAINT $p3 PRIMARY KEY ($p2)" : "DROP CONSTRAINT $p3"; + return "ALTER TABLE $p1 $p4"; + } + } + + private function getSetColumnPkSequenceSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + + switch ($this->driver) { + case 'mysql': + return "select 1"; + case 'pgsql': + return $newColumn->getPk() ? "CREATE SEQUENCE $p3 OWNED BY $p1.$p2" : "DROP SEQUENCE $p3"; + case 'sqlsrv': + return $newColumn->getPk() ? "CREATE SEQUENCE $p3" : "DROP SEQUENCE $p3"; + } + } + + private function getSetColumnPkSequenceStartSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + return "select 1"; + case 'pgsql': + $p3 = $this->pdo->quote($tableName . '_' . $columnName . '_seq'); + return "SELECT setval($p3, (SELECT max($p2)+1 FROM $p1));"; + case 'sqlsrv': + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + $p4 = $this->pdo->query("SELECT max($p2)+1 FROM $p1")->fetchColumn(); + return "ALTER SEQUENCE $p3 RESTART WITH $p4"; + } + } + + private function getSetColumnPkDefaultSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + if ($newColumn->getPk()) { + $p3 = $this->pdo->quote($tableName . '_' . $columnName . '_seq'); + $p4 = "SET DEFAULT nextval($p3)"; + } else { + $p4 = 'DROP DEFAULT'; + } + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p4"; + case 'sqlsrv': + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + $p4 = $this->quote($tableName . '_' . $columnName . '_def'); + if ($newColumn->getPk()) { + return "ALTER TABLE $p1 ADD CONSTRAINT $p4 DEFAULT NEXT VALUE FOR $p3 FOR $p2"; + } else { + return "ALTER TABLE $p1 DROP CONSTRAINT $p4"; + } + } + } + + private function getAddColumnFkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_' . $columnName . '_fkey'); + $p4 = $this->quote($newColumn->getFk()); + $p5 = $this->quote($this->getPrimaryKey($newColumn->getFk())); + + return "ALTER TABLE $p1 ADD CONSTRAINT $p3 FOREIGN KEY ($p2) REFERENCES $p4 ($p5)"; + } + + private function getRemoveColumnFkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($tableName . '_' . $columnName . '_fkey'); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 DROP FOREIGN KEY $p2"; + case 'pgsql': + case 'sqlsrv': + return "ALTER TABLE $p1 DROP CONSTRAINT $p2"; + } + } + + private function getAddTableSQL(ReflectedTable $newTable): string + { + $tableName = $newTable->getName(); + $p1 = $this->quote($tableName); + $fields = []; + $constraints = []; + foreach ($newTable->getColumnNames() as $columnName) { + $pkColumn = $this->getPrimaryKey($tableName); + $newColumn = $newTable->getColumn($columnName); + $f1 = $this->quote($columnName); + $f2 = $this->getColumnType($newColumn, false); + $f3 = $this->quote($tableName . '_' . $columnName . '_fkey'); + $f4 = $this->quote($newColumn->getFk()); + $f5 = $this->quote($this->getPrimaryKey($newColumn->getFk())); + $f6 = $this->quote($tableName . '_' . $pkColumn . '_pkey'); + if ($this->driver == 'sqlite') { + if ($newColumn->getPk()) { + $f2 = str_replace('NULL', 'NULL PRIMARY KEY', $f2); + } + $fields[] = "$f1 $f2"; + if ($newColumn->getFk()) { + $constraints[] = "FOREIGN KEY ($f1) REFERENCES $f4 ($f5)"; + } + } else { + $fields[] = "$f1 $f2"; + if ($newColumn->getPk()) { + $constraints[] = "CONSTRAINT $f6 PRIMARY KEY ($f1)"; + } + if ($newColumn->getFk()) { + $constraints[] = "CONSTRAINT $f3 FOREIGN KEY ($f1) REFERENCES $f4 ($f5)"; + } + } + } + $p2 = implode(',', array_merge($fields, $constraints)); + + return "CREATE TABLE $p1 ($p2);"; + } + + private function getAddColumnSQL(string $tableName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($newColumn->getName()); + $p3 = $this->getColumnType($newColumn, false); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ALTER TABLE $p1 ADD COLUMN $p2 $p3"; + case 'sqlsrv': + return "ALTER TABLE $p1 ADD $p2 $p3"; + case 'sqlite': + return "ALTER TABLE $p1 ADD COLUMN $p2 $p3"; + } + } + + private function getRemoveTableSQL(string $tableName): string + { + $p1 = $this->quote($tableName); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "DROP TABLE $p1 CASCADE;"; + case 'sqlsrv': + return "DROP TABLE $p1;"; + case 'sqlite': + return "DROP TABLE $p1;"; + } + } + + private function getRemoveColumnSQL(string $tableName, string $columnName): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ALTER TABLE $p1 DROP COLUMN $p2 CASCADE;"; + case 'sqlsrv': + return "ALTER TABLE $p1 DROP COLUMN $p2;"; + case 'sqlite': + return "ALTER TABLE $p1 DROP COLUMN $p2;"; + } + } + + public function renameTable(string $tableName, string $newTableName) + { + $sql = $this->getTableRenameSQL($tableName, $newTableName); + return $this->query($sql, []); + } + + public function renameColumn(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getColumnRenameSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function retypeColumn(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getColumnRetypeSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function setColumnNullable(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getSetColumnNullableSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function addColumnPrimaryKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getSetColumnPkConstraintSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + if ($this->canAutoIncrement($newColumn)) { + $sql = $this->getSetColumnPkSequenceSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkSequenceStartSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkDefaultSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + } + return true; + } + + public function removeColumnPrimaryKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + if ($this->canAutoIncrement($newColumn)) { + $sql = $this->getSetColumnPkDefaultSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkSequenceSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + } + $sql = $this->getSetColumnPkConstraintSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + return true; + } + + public function addColumnForeignKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getAddColumnFkConstraintSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function removeColumnForeignKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getRemoveColumnFkConstraintSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function addTable(ReflectedTable $newTable) + { + $sql = $this->getAddTableSQL($newTable); + return $this->query($sql, []); + } + + public function addColumn(string $tableName, ReflectedColumn $newColumn) + { + $sql = $this->getAddColumnSQL($tableName, $newColumn); + return $this->query($sql, []); + } + + public function removeTable(string $tableName) + { + $sql = $this->getRemoveTableSQL($tableName); + return $this->query($sql, []); + } + + public function removeColumn(string $tableName, string $columnName) + { + $sql = $this->getRemoveColumnSQL($tableName, $columnName); + return $this->query($sql, []); + } + + private function query(string $sql, array $arguments): bool + { + $stmt = $this->pdo->prepare($sql); + // echo "- $sql -- " . json_encode($arguments) . "\n"; + return $stmt->execute($arguments); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/GenericReflection.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Database\LazyPdo; + + class GenericReflection + { + private $pdo; + private $driver; + private $database; + private $tables; + private $typeConverter; + + public function __construct(LazyPdo $pdo, string $driver, string $database, array $tables) + { + $this->pdo = $pdo; + $this->driver = $driver; + $this->database = $database; + $this->tables = $tables; + $this->typeConverter = new TypeConverter($driver); + } + + public function getIgnoredTables(): array + { + switch ($this->driver) { + case 'mysql': + return []; + case 'pgsql': + return ['spatial_ref_sys', 'raster_columns', 'raster_overviews', 'geography_columns', 'geometry_columns']; + case 'sqlsrv': + return []; + case 'sqlite': + return []; + } + } + + private function getTablesSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "TABLE_NAME", "TABLE_TYPE" FROM "INFORMATION_SCHEMA"."TABLES" WHERE "TABLE_TYPE" IN (\'BASE TABLE\' , \'VIEW\') AND "TABLE_SCHEMA" = ? ORDER BY BINARY "TABLE_NAME"'; + case 'pgsql': + return 'SELECT c.relname as "TABLE_NAME", c.relkind as "TABLE_TYPE" FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN (\'r\', \'v\') AND n.nspname <> \'pg_catalog\' AND n.nspname <> \'information_schema\' AND n.nspname !~ \'^pg_toast\' AND pg_catalog.pg_table_is_visible(c.oid) AND \'\' <> ? ORDER BY "TABLE_NAME";'; + case 'sqlsrv': + return 'SELECT o.name as "TABLE_NAME", o.xtype as "TABLE_TYPE" FROM sysobjects o WHERE o.xtype IN (\'U\', \'V\') ORDER BY "TABLE_NAME"'; + case 'sqlite': + return 'SELECT t.name as "TABLE_NAME", t.type as "TABLE_TYPE" FROM sqlite_master t WHERE t.type IN (\'table\', \'view\') AND \'\' <> ? ORDER BY "TABLE_NAME"'; + } + } + + private function getTableColumnsSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME", "IS_NULLABLE", "DATA_TYPE", "CHARACTER_MAXIMUM_LENGTH" as "CHARACTER_MAXIMUM_LENGTH", "NUMERIC_PRECISION", "NUMERIC_SCALE", "COLUMN_TYPE" FROM "INFORMATION_SCHEMA"."COLUMNS" WHERE "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ? ORDER BY "ORDINAL_POSITION"'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME", case when a.attnotnull then \'NO\' else \'YES\' end as "IS_NULLABLE", pg_catalog.format_type(a.atttypid, -1) as "DATA_TYPE", case when a.atttypmod < 0 then NULL else a.atttypmod-4 end as "CHARACTER_MAXIMUM_LENGTH", case when a.atttypid != 1700 then NULL else ((a.atttypmod - 4) >> 16) & 65535 end as "NUMERIC_PRECISION", case when a.atttypid != 1700 then NULL else (a.atttypmod - 4) & 65535 end as "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM pg_attribute a JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum;'; + case 'sqlsrv': + return 'SELECT c.name AS "COLUMN_NAME", c.is_nullable AS "IS_NULLABLE", t.Name AS "DATA_TYPE", (c.max_length/2) AS "CHARACTER_MAXIMUM_LENGTH", c.precision AS "NUMERIC_PRECISION", c.scale AS "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM sys.columns c INNER JOIN sys.types t ON c.user_type_id = t.user_type_id WHERE c.object_id = OBJECT_ID(?) AND \'\' <> ? ORDER BY c.column_id'; + case 'sqlite': + return 'SELECT "name" AS "COLUMN_NAME", case when "notnull"==1 then \'no\' else \'yes\' end as "IS_NULLABLE", lower("type") AS "DATA_TYPE", 2147483647 AS "CHARACTER_MAXIMUM_LENGTH", 0 AS "NUMERIC_PRECISION", 0 AS "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM pragma_table_info(?) WHERE \'\' <> ? ORDER BY "cid"'; + } + } + + private function getTablePrimaryKeysSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME" FROM "INFORMATION_SCHEMA"."KEY_COLUMN_USAGE" WHERE "CONSTRAINT_NAME" = \'PRIMARY\' AND "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ?'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME" FROM pg_attribute a JOIN pg_constraint c ON (c.conrelid, c.conkey[1]) = (a.attrelid, a.attnum) JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND c.contype = \'p\''; + case 'sqlsrv': + return 'SELECT c.NAME as "COLUMN_NAME" FROM sys.key_constraints kc inner join sys.objects t on t.object_id = kc.parent_object_id INNER JOIN sys.index_columns ic ON kc.parent_object_id = ic.object_id and kc.unique_index_id = ic.index_id INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id WHERE kc.type = \'PK\' and t.object_id = OBJECT_ID(?) and \'\' <> ?'; + case 'sqlite': + return 'SELECT "name" as "COLUMN_NAME" FROM pragma_table_info(?) WHERE "pk"=1 AND \'\' <> ?'; + } + } + + private function getTableForeignKeysSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME", "REFERENCED_TABLE_NAME" FROM "INFORMATION_SCHEMA"."KEY_COLUMN_USAGE" WHERE "REFERENCED_TABLE_NAME" IS NOT NULL AND "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ?'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME", c.confrelid::regclass::text AS "REFERENCED_TABLE_NAME" FROM pg_attribute a JOIN pg_constraint c ON (c.conrelid, c.conkey[1]) = (a.attrelid, a.attnum) JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND c.contype = \'f\''; + case 'sqlsrv': + return 'SELECT COL_NAME(fc.parent_object_id, fc.parent_column_id) AS "COLUMN_NAME", OBJECT_NAME (f.referenced_object_id) AS "REFERENCED_TABLE_NAME" FROM sys.foreign_keys AS f INNER JOIN sys.foreign_key_columns AS fc ON f.OBJECT_ID = fc.constraint_object_id WHERE f.parent_object_id = OBJECT_ID(?) and \'\' <> ?'; + case 'sqlite': + return 'SELECT "from" AS "COLUMN_NAME", "table" AS "REFERENCED_TABLE_NAME" FROM pragma_foreign_key_list(?) WHERE \'\' <> ?'; + } + } + + public function getDatabaseName(): string + { + return $this->database; + } + + public function getTables(): array + { + $sql = $this->getTablesSQL(); + $results = $this->query($sql, [$this->database]); + $tables = $this->tables; + $results = array_filter($results, function ($v) use ($tables) { + return !$tables || in_array($v['TABLE_NAME'], $tables); + }); + foreach ($results as &$result) { + $map = []; + switch ($this->driver) { + case 'mysql': + $map = ['BASE TABLE' => 'table', 'VIEW' => 'view']; + break; + case 'pgsql': + $map = ['r' => 'table', 'v' => 'view']; + break; + case 'sqlsrv': + $map = ['U' => 'table', 'V' => 'view']; + break; + case 'sqlite': + $map = ['table' => 'table', 'view' => 'view']; + break; + } + $result['TABLE_TYPE'] = $map[trim($result['TABLE_TYPE'])]; + } + return $results; + } + + public function getTableColumns(string $tableName, string $type): array + { + $sql = $this->getTableColumnsSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + if ($type == 'view') { + foreach ($results as &$result) { + $result['IS_NULLABLE'] = false; + } + } + if ($this->driver == 'mysql') { + foreach ($results as &$result) { + // mysql does not properly reflect display width of types + preg_match('|([a-z]+)(\(([0-9]+)(,([0-9]+))?\))?|', $result['DATA_TYPE'], $matches); + $result['DATA_TYPE'] = $matches[1]; + if (!$result['CHARACTER_MAXIMUM_LENGTH']) { + if (isset($matches[3])) { + $result['NUMERIC_PRECISION'] = $matches[3]; + } + if (isset($matches[5])) { + $result['NUMERIC_SCALE'] = $matches[5]; + } + } + } + } + if ($this->driver == 'sqlite') { + foreach ($results as &$result) { + // sqlite does not properly reflect display width of types + preg_match('|([a-z]+)(\(([0-9]+)(,([0-9]+))?\))?|', $result['DATA_TYPE'], $matches); + if (isset($matches[1])) { + $result['DATA_TYPE'] = $matches[1]; + } else { + $result['DATA_TYPE'] = 'integer'; + } + if (isset($matches[5])) { + $result['NUMERIC_PRECISION'] = $matches[3]; + $result['NUMERIC_SCALE'] = $matches[5]; + } else if (isset($matches[3])) { + $result['CHARACTER_MAXIMUM_LENGTH'] = $matches[3]; + } + } + } + return $results; + } + + public function getTablePrimaryKeys(string $tableName): array + { + $sql = $this->getTablePrimaryKeysSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + $primaryKeys = []; + foreach ($results as $result) { + $primaryKeys[] = $result['COLUMN_NAME']; + } + return $primaryKeys; + } + + public function getTableForeignKeys(string $tableName): array + { + $sql = $this->getTableForeignKeysSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + $foreignKeys = []; + foreach ($results as $result) { + $foreignKeys[$result['COLUMN_NAME']] = $result['REFERENCED_TABLE_NAME']; + } + return $foreignKeys; + } + + public function toJdbcType(string $type, string $size): string + { + return $this->typeConverter->toJdbc($type, $size); + } + + private function query(string $sql, array $parameters): array + { + $stmt = $this->pdo->prepare($sql); + //echo "- $sql -- " . json_encode($parameters, JSON_UNESCAPED_UNICODE) . "\n"; + $stmt->execute($parameters); + return $stmt->fetchAll(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/LazyPdo.php +namespace Tqdev\PhpCrudApi\Database { + + class LazyPdo extends \PDO + { + private $dsn; + private $user; + private $password; + private $options; + private $commands; + + private $pdo = null; + + public function __construct(string $dsn, /*?string*/ $user = null, /*?string*/ $password = null, array $options = array()) + { + $this->dsn = $dsn; + $this->user = $user; + $this->password = $password; + $this->options = $options; + $this->commands = array(); + // explicitly NOT calling super::__construct + } + + public function addInitCommand(string $command)/*: void*/ + { + $this->commands[] = $command; + } + + private function pdo() + { + if (!$this->pdo) { + $this->pdo = new \PDO($this->dsn, $this->user, $this->password, $this->options); + foreach ($this->commands as $command) { + $this->pdo->query($command); + } + } + return $this->pdo; + } + + public function reconstruct(string $dsn, /*?string*/ $user = null, /*?string*/ $password = null, array $options = array()): bool + { + $this->dsn = $dsn; + $this->user = $user; + $this->password = $password; + $this->options = $options; + $this->commands = array(); + if ($this->pdo) { + $this->pdo = null; + return true; + } + return false; + } + + public function inTransaction(): bool + { + // Do not call parent method if there is no pdo object + return $this->pdo && parent::inTransaction(); + } + + public function setAttribute($attribute, $value): bool + { + if ($this->pdo) { + return $this->pdo()->setAttribute($attribute, $value); + } + $this->options[$attribute] = $value; + return true; + } + + public function getAttribute($attribute): mixed + { + return $this->pdo()->getAttribute($attribute); + } + + public function beginTransaction(): bool + { + return $this->pdo()->beginTransaction(); + } + + public function commit(): bool + { + return $this->pdo()->commit(); + } + + public function rollBack(): bool + { + return $this->pdo()->rollBack(); + } + + public function errorCode(): mixed + { + return $this->pdo()->errorCode(); + } + + public function errorInfo(): array + { + return $this->pdo()->errorInfo(); + } + + public function exec($query): int + { + return $this->pdo()->exec($query); + } + + public function prepare($statement, $options = array()) + { + return $this->pdo()->prepare($statement, $options); + } + + public function quote($string, $parameter_type = null): string + { + return $this->pdo()->quote($string, $parameter_type); + } + + public function lastInsertId(/* ?string */$name = null): string + { + return $this->pdo()->lastInsertId($name); + } + + public function query(string $statement): \PDOStatement + { + return call_user_func_array(array($this->pdo(), 'query'), func_get_args()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/TypeConverter.php +namespace Tqdev\PhpCrudApi\Database { + + class TypeConverter + { + private $driver; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + private $fromJdbc = [ + 'mysql' => [ + 'clob' => 'longtext', + 'boolean' => 'tinyint(1)', + 'blob' => 'longblob', + 'timestamp' => 'datetime', + ], + 'pgsql' => [ + 'clob' => 'text', + 'blob' => 'bytea', + 'float' => 'real', + 'double' => 'double precision', + 'varbinary' => 'bytea', + ], + 'sqlsrv' => [ + 'boolean' => 'bit', + 'varchar' => 'nvarchar', + 'clob' => 'ntext', + 'blob' => 'image', + 'time' => 'time(0)', + 'timestamp' => 'datetime2(0)', + 'double' => 'float', + 'float' => 'real', + ], + ]; + + private $toJdbc = [ + 'simplified' => [ + 'char' => 'varchar', + 'longvarchar' => 'clob', + 'nchar' => 'varchar', + 'nvarchar' => 'varchar', + 'longnvarchar' => 'clob', + 'binary' => 'varbinary', + 'longvarbinary' => 'blob', + 'tinyint' => 'integer', + 'smallint' => 'integer', + 'real' => 'float', + 'numeric' => 'decimal', + 'nclob' => 'clob', + 'time_with_timezone' => 'time', + 'timestamp_with_timezone' => 'timestamp', + ], + 'mysql' => [ + 'tinyint(1)' => 'boolean', + 'bit(1)' => 'boolean', + 'tinyblob' => 'blob', + 'mediumblob' => 'blob', + 'longblob' => 'blob', + 'tinytext' => 'clob', + 'mediumtext' => 'clob', + 'longtext' => 'clob', + 'text' => 'clob', + 'mediumint' => 'integer', + 'int' => 'integer', + 'polygon' => 'geometry', + 'point' => 'geometry', + 'datetime' => 'timestamp', + 'year' => 'integer', + 'enum' => 'varchar', + 'set' => 'varchar', + 'json' => 'clob', + ], + 'pgsql' => [ + 'bigserial' => 'bigint', + 'bit varying' => 'bit', + 'box' => 'geometry', + 'bytea' => 'blob', + 'bpchar' => 'char', + 'character varying' => 'varchar', + 'character' => 'char', + 'cidr' => 'varchar', + 'circle' => 'geometry', + 'double precision' => 'double', + 'inet' => 'integer', + //'interval [ fields ]' + 'json' => 'clob', + 'jsonb' => 'clob', + 'line' => 'geometry', + 'lseg' => 'geometry', + 'macaddr' => 'varchar', + 'money' => 'decimal', + 'path' => 'geometry', + 'point' => 'geometry', + 'polygon' => 'geometry', + 'real' => 'float', + 'serial' => 'integer', + 'text' => 'clob', + 'time without time zone' => 'time', + 'time with time zone' => 'time_with_timezone', + 'timestamp without time zone' => 'timestamp', + 'timestamp with time zone' => 'timestamp_with_timezone', + //'tsquery'= + //'tsvector' + //'txid_snapshot' + 'uuid' => 'char', + 'xml' => 'clob', + ], + // source: https://docs.microsoft.com/en-us/sql/connect/jdbc/using-basic-data-types?view=sql-server-2017 + 'sqlsrv' => [ + 'varbinary()' => 'blob', + 'bit' => 'boolean', + 'datetime' => 'timestamp', + 'datetime2' => 'timestamp', + 'float' => 'double', + 'image' => 'blob', + 'int' => 'integer', + 'money' => 'decimal', + 'ntext' => 'clob', + 'smalldatetime' => 'timestamp', + 'smallmoney' => 'decimal', + 'text' => 'clob', + 'timestamp' => 'binary', + 'udt' => 'varbinary', + 'uniqueidentifier' => 'char', + 'xml' => 'clob', + ], + 'sqlite' => [ + 'tinytext' => 'clob', + 'text' => 'clob', + 'mediumtext' => 'clob', + 'longtext' => 'clob', + 'mediumint' => 'integer', + 'int' => 'integer', + 'bigint' => 'bigint', + 'int2' => 'smallint', + 'int4' => 'integer', + 'int8' => 'bigint', + 'double precision' => 'double', + 'datetime' => 'timestamp' + ], + ]; + + // source: https://docs.oracle.com/javase/9/docs/api/java/sql/Types.html + private $valid = [ + //'array' => true, + 'bigint' => true, + 'binary' => true, + 'bit' => true, + 'blob' => true, + 'boolean' => true, + 'char' => true, + 'clob' => true, + //'datalink' => true, + 'date' => true, + 'decimal' => true, + //'distinct' => true, + 'double' => true, + 'float' => true, + 'integer' => true, + //'java_object' => true, + 'longnvarchar' => true, + 'longvarbinary' => true, + 'longvarchar' => true, + 'nchar' => true, + 'nclob' => true, + //'null' => true, + 'numeric' => true, + 'nvarchar' => true, + //'other' => true, + 'real' => true, + //'ref' => true, + //'ref_cursor' => true, + //'rowid' => true, + 'smallint' => true, + //'sqlxml' => true, + //'struct' => true, + 'time' => true, + 'time_with_timezone' => true, + 'timestamp' => true, + 'timestamp_with_timezone' => true, + 'tinyint' => true, + 'varbinary' => true, + 'varchar' => true, + // extra: + 'geometry' => true, + ]; + + public function toJdbc(string $type, string $size): string + { + $jdbcType = strtolower($type); + if (isset($this->toJdbc[$this->driver]["$jdbcType($size)"])) { + $jdbcType = $this->toJdbc[$this->driver]["$jdbcType($size)"]; + } + if (isset($this->toJdbc[$this->driver][$jdbcType])) { + $jdbcType = $this->toJdbc[$this->driver][$jdbcType]; + } + if (isset($this->toJdbc['simplified'][$jdbcType])) { + $jdbcType = $this->toJdbc['simplified'][$jdbcType]; + } + if (!isset($this->valid[$jdbcType])) { + //throw new \Exception("Unsupported type '$jdbcType' for driver '$this->driver'"); + $jdbcType = 'clob'; + } + return $jdbcType; + } + + public function fromJdbc(string $type): string + { + $jdbcType = strtolower($type); + if (isset($this->fromJdbc[$this->driver][$jdbcType])) { + $jdbcType = $this->fromJdbc[$this->driver][$jdbcType]; + } + return $jdbcType; + } + } +} + +// file: src/Tqdev/PhpCrudApi/GeoJson/Feature.php +namespace Tqdev\PhpCrudApi\GeoJson { + + class Feature implements \JsonSerializable + { + private $id; + private $properties; + private $geometry; + + public function __construct($id, array $properties, /*?Geometry*/ $geometry) + { + $this->id = $id; + $this->properties = $properties; + $this->geometry = $geometry; + } + + public function serialize() + { + return [ + 'type' => 'Feature', + 'id' => $this->id, + 'properties' => $this->properties, + 'geometry' => $this->geometry, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/GeoJson/FeatureCollection.php +namespace Tqdev\PhpCrudApi\GeoJson { + + class FeatureCollection implements \JsonSerializable + { + private $features; + + private $results; + + public function __construct(array $features, int $results) + { + $this->features = $features; + $this->results = $results; + } + + public function serialize() + { + return [ + 'type' => 'FeatureCollection', + 'features' => $this->features, + 'results' => $this->results, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize(), function ($v) { + return $v !== 0; + }); + } + } +} + +// file: src/Tqdev/PhpCrudApi/GeoJson/GeoJsonService.php +namespace Tqdev\PhpCrudApi\GeoJson { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\GeoJson\FeatureCollection; + use Tqdev\PhpCrudApi\Record\RecordService; + + class GeoJsonService + { + private $reflection; + private $records; + + public function __construct(ReflectionService $reflection, RecordService $records) + { + $this->reflection = $reflection; + $this->records = $records; + } + + public function hasTable(string $table): bool + { + return $this->reflection->hasTable($table); + } + + public function getType(string $table): string + { + return $this->reflection->getType($table); + } + + private function getGeometryColumnName(string $tableName, array &$params): string + { + $geometryParam = isset($params['geometry']) ? $params['geometry'][0] : ''; + $table = $this->reflection->getTable($tableName); + $geometryColumnName = ''; + foreach ($table->getColumnNames() as $columnName) { + if ($geometryParam && $geometryParam != $columnName) { + continue; + } + $column = $table->getColumn($columnName); + if ($column->isGeometry()) { + $geometryColumnName = $columnName; + break; + } + } + if ($geometryColumnName) { + $params['mandatory'][] = $tableName . "." . $geometryColumnName; + } + return $geometryColumnName; + } + + private function setBoudingBoxFilter(string $geometryColumnName, array &$params) + { + $boundingBox = isset($params['bbox']) ? $params['bbox'][0] : ''; + if ($boundingBox) { + $c = explode(',', $boundingBox); + if (!isset($params['filter'])) { + $params['filter'] = array(); + } + $params['filter'][] = "$geometryColumnName,sin,POLYGON(($c[0] $c[1],$c[2] $c[1],$c[2] $c[3],$c[0] $c[3],$c[0] $c[1]))"; + } + $tile = isset($params['tile']) ? $params['tile'][0] : ''; + if ($tile) { + $zxy = explode(',', $tile); + if (count($zxy) == 3) { + list($z, $x, $y) = $zxy; + $c = array(); + $c = array_merge($c, $this->convertTileToLatLonOfUpperLeftCorner($z, $x, $y)); + $c = array_merge($c, $this->convertTileToLatLonOfUpperLeftCorner($z, $x + 1, $y + 1)); + $params['filter'][] = "$geometryColumnName,sin,POLYGON(($c[0] $c[1],$c[2] $c[1],$c[2] $c[3],$c[0] $c[3],$c[0] $c[1]))"; + } + } + } + + private function convertTileToLatLonOfUpperLeftCorner($z, $x, $y): array + { + $n = pow(2, $z); + $lon = $x / $n * 360.0 - 180.0; + $lat = rad2deg(atan(sinh(pi() * (1 - 2 * $y / $n)))); + return [$lon, $lat]; + } + + private function convertRecordToFeature(/*object*/$record, string $primaryKeyColumnName, string $geometryColumnName) + { + $id = null; + if ($primaryKeyColumnName) { + $id = $record[$primaryKeyColumnName]; + } + $geometry = null; + if (isset($record[$geometryColumnName])) { + $geometry = Geometry::fromWkt($record[$geometryColumnName]); + } + $properties = array_diff_key($record, [$primaryKeyColumnName => true, $geometryColumnName => true]); + return new Feature($id, $properties, $geometry); + } + + private function getPrimaryKeyColumnName(string $tableName, array &$params): string + { + $primaryKeyColumn = $this->reflection->getTable($tableName)->getPk(); + if (!$primaryKeyColumn) { + return ''; + } + $primaryKeyColumnName = $primaryKeyColumn->getName(); + $params['mandatory'][] = $tableName . "." . $primaryKeyColumnName; + return $primaryKeyColumnName; + } + + public function _list(string $tableName, array $params): FeatureCollection + { + $geometryColumnName = $this->getGeometryColumnName($tableName, $params); + $this->setBoudingBoxFilter($geometryColumnName, $params); + $primaryKeyColumnName = $this->getPrimaryKeyColumnName($tableName, $params); + $records = $this->records->_list($tableName, $params); + $features = array(); + foreach ($records->getRecords() as $record) { + $features[] = $this->convertRecordToFeature($record, $primaryKeyColumnName, $geometryColumnName); + } + return new FeatureCollection($features, $records->getResults()); + } + + public function read(string $tableName, string $id, array $params): Feature + { + $geometryColumnName = $this->getGeometryColumnName($tableName, $params); + $primaryKeyColumnName = $this->getPrimaryKeyColumnName($tableName, $params); + $record = $this->records->read($tableName, $id, $params); + return $this->convertRecordToFeature($record, $primaryKeyColumnName, $geometryColumnName); + } + } +} + +// file: src/Tqdev/PhpCrudApi/GeoJson/Geometry.php +namespace Tqdev\PhpCrudApi\GeoJson { + + class Geometry implements \JsonSerializable + { + private $type; + private $geometry; + + public static $types = [ + "Point", + "MultiPoint", + "LineString", + "MultiLineString", + "Polygon", + "MultiPolygon", + //"GeometryCollection", + ]; + + public function __construct(string $type, array $coordinates) + { + $this->type = $type; + $this->coordinates = $coordinates; + } + + public static function fromWkt(string $wkt): Geometry + { + $bracket = strpos($wkt, '('); + $type = strtoupper(trim(substr($wkt, 0, $bracket))); + $supported = false; + foreach (Geometry::$types as $typeName) { + if (strtoupper($typeName) == $type) { + $type = $typeName; + $supported = true; + } + } + if (!$supported) { + throw new \Exception('Geometry type not supported: ' . $type); + } + $coordinates = substr($wkt, $bracket); + if (substr($type, -5) != 'Point' || ($type == 'MultiPoint' && $coordinates[1] != '(')) { + $coordinates = preg_replace('|([0-9\-\.]+ )+([0-9\-\.]+)|', '[\1\2]', $coordinates); + } + $coordinates = str_replace(['(', ')', ', ', ' '], ['[', ']', ',', ','], $coordinates); + $coordinates = json_decode($coordinates); + if (!$coordinates) { + throw new \Exception('Could not decode WKT: ' . $wkt); + } + return new Geometry($type, $coordinates); + } + + public function serialize() + { + return [ + 'type' => $this->type, + 'coordinates' => $this->coordinates, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/Base/Middleware.php +namespace Tqdev\PhpCrudApi\Middleware\Base { + + use Psr\Http\Server\MiddlewareInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + + abstract class Middleware implements MiddlewareInterface + { + protected $next; + protected $responder; + private $properties; + + public function __construct(Router $router, Responder $responder, array $properties) + { + $router->load($this); + $this->responder = $responder; + $this->properties = $properties; + } + + protected function getArrayProperty(string $key, string $default): array + { + return array_filter(array_map('trim', explode(',', $this->getProperty($key, $default)))); + } + + protected function getMapProperty(string $key, string $default): array + { + $pairs = $this->getArrayProperty($key, $default); + $result = array(); + foreach ($pairs as $pair) { + if (strpos($pair, ':')) { + list($k, $v) = explode(':', $pair, 2); + $result[trim($k)] = trim($v); + } else { + $result[] = trim($pair); + } + } + return $result; + } + + protected function getProperty(string $key, $default) + { + return isset($this->properties[$key]) ? $this->properties[$key] : $default; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/Communication/VariableStore.php +namespace Tqdev\PhpCrudApi\Middleware\Communication { + + class VariableStore + { + public static $values = array(); + + public static function get(string $key) + { + if (isset(self::$values[$key])) { + return self::$values[$key]; + } + return null; + } + + public static function set(string $key, /* object */ $value) + { + self::$values[$key] = $value; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/Router/Router.php +namespace Tqdev\PhpCrudApi\Middleware\Router { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + + interface Router extends RequestHandlerInterface + { + public function register(string $method, string $path, array $handler); + + public function load(Middleware $middleware); + + public function route(ServerRequestInterface $request): ResponseInterface; + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/Router/SimpleRouter.php +namespace Tqdev\PhpCrudApi\Middleware\Router { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Cache\Cache; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\PathTree; + use Tqdev\PhpCrudApi\RequestUtils; + use Tqdev\PhpCrudApi\ResponseUtils; + + class SimpleRouter implements Router + { + private $basePath; + private $responder; + private $cache; + private $ttl; + private $debug; + private $registration; + private $routes; + private $routeHandlers; + private $middlewares; + + public function __construct(string $basePath, Responder $responder, Cache $cache, int $ttl, bool $debug) + { + $this->basePath = rtrim($this->detectBasePath($basePath), '/'); + $this->responder = $responder; + $this->cache = $cache; + $this->ttl = $ttl; + $this->debug = $debug; + $this->registration = true; + $this->routes = $this->loadPathTree(); + $this->routeHandlers = []; + $this->middlewares = array(); + } + + private function detectBasePath(string $basePath): string + { + if ($basePath) { + return $basePath; + } + if (isset($_SERVER['REQUEST_URI'])) { + $fullPath = urldecode(explode('?', $_SERVER['REQUEST_URI'])[0]); + if (isset($_SERVER['PATH_INFO'])) { + $path = $_SERVER['PATH_INFO']; + if (substr($fullPath, -1 * strlen($path)) == $path) { + return substr($fullPath, 0, -1 * strlen($path)); + } + } + if ('/' . basename(__FILE__) == $fullPath) { + return $fullPath; + } + } + return '/'; + } + + private function loadPathTree(): PathTree + { + $data = $this->cache->get('PathTree'); + if ($data != '') { + $tree = PathTree::fromJson(json_decode(gzuncompress($data))); + $this->registration = false; + } else { + $tree = new PathTree(); + } + return $tree; + } + + public function register(string $method, string $path, array $handler) + { + $routeNumber = count($this->routeHandlers); + $this->routeHandlers[$routeNumber] = $handler; + if ($this->registration) { + $path = trim($path, '/'); + $parts = array(); + if ($path) { + $parts = explode('/', $path); + } + array_unshift($parts, $method); + $this->routes->put($parts, $routeNumber); + } + } + + public function load(Middleware $middleware) /*: void*/ + { + array_push($this->middlewares, $middleware); + } + + public function route(ServerRequestInterface $request): ResponseInterface + { + if ($this->registration) { + $data = gzcompress(json_encode($this->routes, JSON_UNESCAPED_UNICODE)); + $this->cache->set('PathTree', $data, $this->ttl); + } + + return $this->handle($request); + } + + private function getRouteNumbers(ServerRequestInterface $request): array + { + $method = strtoupper($request->getMethod()); + $path = array(); + $segment = $method; + for ($i = 1; strlen($segment) > 0; $i++) { + array_push($path, $segment); + $segment = RequestUtils::getPathSegment($request, $i); + } + return $this->routes->match($path); + } + + private function removeBasePath(ServerRequestInterface $request): ServerRequestInterface + { + $path = $request->getUri()->getPath(); + if (substr($path, 0, strlen($this->basePath)) == $this->basePath) { + $path = substr($path, strlen($this->basePath)); + $request = $request->withUri($request->getUri()->withPath($path)); + } + return $request; + } + + public function getBasePath(): string + { + return $this->basePath; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $request = $this->removeBasePath($request); + + if (count($this->middlewares)) { + $handler = array_pop($this->middlewares); + return $handler->process($request, $this); + } + + $routeNumbers = $this->getRouteNumbers($request); + if (count($routeNumbers) == 0) { + return $this->responder->error(ErrorCode::ROUTE_NOT_FOUND, $request->getUri()->getPath()); + } + try { + $response = call_user_func($this->routeHandlers[$routeNumbers[0]], $request); + } catch (\PDOException $e) { + if (strpos(strtolower($e->getMessage()), 'duplicate') !== false) { + $response = $this->responder->error(ErrorCode::DUPLICATE_KEY_EXCEPTION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'unique constraint') !== false) { + $response = $this->responder->error(ErrorCode::DUPLICATE_KEY_EXCEPTION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'default value') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'allow nulls') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'constraint') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } else { + $response = $this->responder->error(ErrorCode::ERROR_NOT_FOUND, ''); + } + if ($this->debug) { + $response = ResponseUtils::addExceptionHeaders($response, $e); + } + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/AjaxOnlyMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class AjaxOnlyMiddleware extends Middleware + { + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $method = $request->getMethod(); + $excludeMethods = $this->getArrayProperty('excludeMethods', 'OPTIONS,GET'); + if (!in_array($method, $excludeMethods)) { + $headerName = $this->getProperty('headerName', 'X-Requested-With'); + $headerValue = $this->getProperty('headerValue', 'XMLHttpRequest'); + if ($headerValue != RequestUtils::getHeader($request, $headerName)) { + return $this->responder->error(ErrorCode::ONLY_AJAX_REQUESTS_ALLOWED, $method); + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/AuthorizationMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\FilterInfo; + use Tqdev\PhpCrudApi\RequestUtils; + + class AuthorizationMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function handleColumns(string $operation, string $tableName) /*: void*/ + { + $columnHandler = $this->getProperty('columnHandler', ''); + if ($columnHandler) { + $table = $this->reflection->getTable($tableName); + foreach ($table->getColumnNames() as $columnName) { + $allowed = call_user_func($columnHandler, $operation, $tableName, $columnName); + if (!$allowed) { + $table->removeColumn($columnName); + } + } + } + } + + private function handleTable(string $operation, string $tableName) /*: void*/ + { + if (!$this->reflection->hasTable($tableName)) { + return; + } + $allowed = true; + $tableHandler = $this->getProperty('tableHandler', ''); + if ($tableHandler) { + $allowed = call_user_func($tableHandler, $operation, $tableName); + } + if (!$allowed) { + $this->reflection->removeTable($tableName); + } else { + $this->handleColumns($operation, $tableName); + } + } + + private function handleRecords(string $operation, string $tableName) /*: void*/ + { + if (!$this->reflection->hasTable($tableName)) { + return; + } + $recordHandler = $this->getProperty('recordHandler', ''); + if ($recordHandler) { + $query = call_user_func($recordHandler, $operation, $tableName); + $filters = new FilterInfo(); + $table = $this->reflection->getTable($tableName); + $query = str_replace('][]=', ']=', str_replace('=', '[]=', $query)); + parse_str($query, $params); + $condition = $filters->getCombinedConditions($table, $params); + VariableStore::set("authorization.conditions.$tableName", $condition); + } + } + + private function pathHandler(string $path) /*: bool*/ + { + $pathHandler = $this->getProperty('pathHandler', ''); + return $pathHandler ? call_user_func($pathHandler, $path) : true; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $path = RequestUtils::getPathSegment($request, 1); + + if (!$this->pathHandler($path)) { + return $this->responder->error(ErrorCode::ROUTE_NOT_FOUND, $request->getUri()->getPath()); + } + + $operation = RequestUtils::getOperation($request); + $tableNames = RequestUtils::getTableNames($request, $this->reflection); + foreach ($tableNames as $tableName) { + $this->handleTable($operation, $tableName); + if ($path == 'records') { + $this->handleRecords($operation, $tableName); + } + } + if ($path == 'openapi') { + VariableStore::set('authorization.tableHandler', $this->getProperty('tableHandler', '')); + VariableStore::set('authorization.columnHandler', $this->getProperty('columnHandler', '')); + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class BasicAuthMiddleware extends Middleware + { + private function hasCorrectPassword(string $username, string $password, array &$passwords): bool + { + $hash = isset($passwords[$username]) ? $passwords[$username] : false; + if ($hash && password_verify($password, $hash)) { + if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { + $passwords[$username] = password_hash($password, PASSWORD_DEFAULT); + } + return true; + } + return false; + } + + private function getValidUsername(string $username, string $password, string $passwordFile): string + { + $passwords = $this->readPasswords($passwordFile); + $valid = $this->hasCorrectPassword($username, $password, $passwords); + $this->writePasswords($passwordFile, $passwords); + return $valid ? $username : ''; + } + + private function readPasswords(string $passwordFile): array + { + $passwords = []; + $passwordLines = file($passwordFile); + foreach ($passwordLines as $passwordLine) { + if (strpos($passwordLine, ':') !== false) { + list($username, $hash) = explode(':', trim($passwordLine), 2); + if (strlen($hash) > 0 && $hash[0] != '$') { + $hash = password_hash($hash, PASSWORD_DEFAULT); + } + $passwords[$username] = $hash; + } + } + return $passwords; + } + + private function writePasswords(string $passwordFile, array $passwords): bool + { + $success = false; + $passwordFileContents = ''; + foreach ($passwords as $username => $hash) { + $passwordFileContents .= "$username:$hash\n"; + } + if (file_get_contents($passwordFile) != $passwordFileContents) { + $success = file_put_contents($passwordFile, $passwordFileContents) !== false; + } + return $success; + } + + private function getAuthorizationCredentials(ServerRequestInterface $request): string + { + if (isset($_SERVER['PHP_AUTH_USER'])) { + return $_SERVER['PHP_AUTH_USER'] . ':' . $_SERVER['PHP_AUTH_PW']; + } + $header = RequestUtils::getHeader($request, 'Authorization'); + $parts = explode(' ', trim($header), 2); + if (count($parts) != 2) { + return ''; + } + if ($parts[0] != 'Basic') { + return ''; + } + return base64_decode(strtr($parts[1], '-_', '+/')); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $credentials = $this->getAuthorizationCredentials($request); + if ($credentials) { + list($username, $password) = array('', ''); + if (strpos($credentials, ':') !== false) { + list($username, $password) = explode(':', $credentials, 2); + } + $passwordFile = $this->getProperty('passwordFile', '.htpasswd'); + $validUser = $this->getValidUsername($username, $password, $passwordFile); + $_SESSION['username'] = $validUser; + if (!$validUser) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (!headers_sent()) { + session_regenerate_id(); + } + } + if (!isset($_SESSION['username']) || !$_SESSION['username']) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + $response = $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + $realm = $this->getProperty('realm', 'Username and password required'); + $response = $response->withHeader('WWW-Authenticate', "Basic realm=\"$realm\""); + return $response; + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/CorsMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\ResponseFactory; + use Tqdev\PhpCrudApi\ResponseUtils; + + class CorsMiddleware extends Middleware + { + private $debug; + + public function __construct(Router $router, Responder $responder, array $properties, bool $debug) + { + parent::__construct($router, $responder, $properties); + $this->debug = $debug; + } + + private function isOriginAllowed(string $origin, string $allowedOrigins): bool + { + $found = false; + foreach (explode(',', $allowedOrigins) as $allowedOrigin) { + $hostname = preg_quote(strtolower(trim($allowedOrigin))); + $regex = '/^' . str_replace('\*', '.*', $hostname) . '$/'; + if (preg_match($regex, $origin)) { + $found = true; + break; + } + } + return $found; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $method = $request->getMethod(); + $origin = count($request->getHeader('Origin')) ? $request->getHeader('Origin')[0] : ''; + $allowedOrigins = $this->getProperty('allowedOrigins', '*'); + if ($origin && !$this->isOriginAllowed($origin, $allowedOrigins)) { + $response = $this->responder->error(ErrorCode::ORIGIN_FORBIDDEN, $origin); + } elseif ($method == 'OPTIONS') { + $response = ResponseFactory::fromStatus(ResponseFactory::OK); + $allowHeaders = $this->getProperty('allowHeaders', 'Content-Type, X-XSRF-TOKEN, X-Authorization'); + if ($this->debug) { + $allowHeaders = implode(', ', array_filter([$allowHeaders, 'X-Exception-Name, X-Exception-Message, X-Exception-File'])); + } + if ($allowHeaders) { + $response = $response->withHeader('Access-Control-Allow-Headers', $allowHeaders); + } + $allowMethods = $this->getProperty('allowMethods', 'OPTIONS, GET, PUT, POST, DELETE, PATCH'); + if ($allowMethods) { + $response = $response->withHeader('Access-Control-Allow-Methods', $allowMethods); + } + $allowCredentials = $this->getProperty('allowCredentials', 'true'); + if ($allowCredentials) { + $response = $response->withHeader('Access-Control-Allow-Credentials', $allowCredentials); + } + $maxAge = $this->getProperty('maxAge', '1728000'); + if ($maxAge) { + $response = $response->withHeader('Access-Control-Max-Age', $maxAge); + } + $exposeHeaders = $this->getProperty('exposeHeaders', ''); + if ($this->debug) { + $exposeHeaders = implode(', ', array_filter([$exposeHeaders, 'X-Exception-Name, X-Exception-Message, X-Exception-File'])); + } + if ($exposeHeaders) { + $response = $response->withHeader('Access-Control-Expose-Headers', $exposeHeaders); + } + } else { + $response = null; + try { + $response = $next->handle($request); + } catch (\Throwable $e) { + $response = $this->responder->error(ErrorCode::ERROR_NOT_FOUND, $e->getMessage()); + if ($this->debug) { + $response = ResponseUtils::addExceptionHeaders($response, $e); + } + } + } + if ($origin) { + $allowCredentials = $this->getProperty('allowCredentials', 'true'); + if ($allowCredentials) { + $response = $response->withHeader('Access-Control-Allow-Credentials', $allowCredentials); + } + $response = $response->withHeader('Access-Control-Allow-Origin', $origin); + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/CustomizationMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + + class CustomizationMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + $tableName = RequestUtils::getPathSegment($request, 2); + $beforeHandler = $this->getProperty('beforeHandler', ''); + $environment = (object) array(); + if ($beforeHandler !== '') { + $result = call_user_func($beforeHandler, $operation, $tableName, $request, $environment); + $request = $result ?: $request; + } + $response = $next->handle($request); + $afterHandler = $this->getProperty('afterHandler', ''); + if ($afterHandler !== '') { + $result = call_user_func($afterHandler, $operation, $tableName, $response, $environment); + $response = $result ?: $response; + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\OrderingInfo; + use Tqdev\PhpCrudApi\RequestUtils; + + class DbAuthMiddleware extends Middleware + { + private $reflection; + private $db; + private $ordering; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection, GenericDB $db) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + $this->db = $db; + $this->ordering = new OrderingInfo(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $path = RequestUtils::getPathSegment($request, 1); + $method = $request->getMethod(); + if ($method == 'POST' && in_array($path, ['login', 'register', 'password'])) { + $body = $request->getParsedBody(); + $username = isset($body->username) ? $body->username : ''; + $password = isset($body->password) ? $body->password : ''; + $newPassword = isset($body->newPassword) ? $body->newPassword : ''; + $tableName = $this->getProperty('usersTable', 'users'); + $table = $this->reflection->getTable($tableName); + $usernameColumnName = $this->getProperty('usernameColumn', 'username'); + $usernameColumn = $table->getColumn($usernameColumnName); + $passwordColumnName = $this->getProperty('passwordColumn', 'password'); + $passwordLength = $this->getProperty('passwordLength', '12'); + $pkName = $table->getPk()->getName(); + $registerUser = $this->getProperty('registerUser', ''); + $condition = new ColumnCondition($usernameColumn, 'eq', $username); + $returnedColumns = $this->getProperty('returnedColumns', ''); + if (!$returnedColumns) { + $columnNames = $table->getColumnNames(); + } else { + $columnNames = array_map('trim', explode(',', $returnedColumns)); + $columnNames[] = $passwordColumnName; + $columnNames[] = $pkName; + } + $columnOrdering = $this->ordering->getDefaultColumnOrdering($table); + if ($path == 'register') { + if (!$registerUser) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (strlen($password) < $passwordLength) { + return $this->responder->error(ErrorCode::PASSWORD_TOO_SHORT, $passwordLength); + } + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + if (!empty($users)) { + return $this->responder->error(ErrorCode::USER_ALREADY_EXIST, $username); + } + $data = json_decode($registerUser, true); + $data = is_array($data) ? $data : []; + $data[$usernameColumnName] = $username; + $data[$passwordColumnName] = password_hash($password, PASSWORD_DEFAULT); + $this->db->createSingle($table, $data); + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if ($path == 'login') { + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + if (password_verify($password, $user[$passwordColumnName]) == 1) { + if (!headers_sent()) { + session_regenerate_id(true); + } + unset($user[$passwordColumnName]); + $_SESSION['user'] = $user; + return $this->responder->success($user); + } + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if ($path == 'password') { + if ($username != ($_SESSION['user'][$usernameColumnName] ?? '')) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (strlen($newPassword) < $passwordLength) { + return $this->responder->error(ErrorCode::PASSWORD_TOO_SHORT, $passwordLength); + } + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + if (password_verify($password, $user[$passwordColumnName]) == 1) { + if (!headers_sent()) { + session_regenerate_id(true); + } + $data = [$passwordColumnName => password_hash($newPassword, PASSWORD_DEFAULT)]; + $this->db->updateSingle($table, $data, $user[$pkName]); + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + } + if ($method == 'POST' && $path == 'logout') { + if (isset($_SESSION['user'])) { + $user = $_SESSION['user']; + unset($_SESSION['user']); + if (session_status() != PHP_SESSION_NONE) { + session_destroy(); + } + return $this->responder->success($user); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + if ($method == 'GET' && $path == 'me') { + if (isset($_SESSION['user'])) { + return $this->responder->success($_SESSION['user']); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + if (!isset($_SESSION['user']) || !$_SESSION['user']) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/FirewallMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + + class FirewallMiddleware extends Middleware + { + private function ipMatch(string $ip, string $cidr): bool + { + if (strpos($cidr, '/') !== false) { + list($subnet, $mask) = explode('/', trim($cidr)); + if ((ip2long($ip) & ~((1 << (32 - $mask)) - 1)) == ip2long($subnet)) { + return true; + } + } else { + if (ip2long($ip) == ip2long($cidr)) { + return true; + } + } + return false; + } + + private function isIpAllowed(string $ipAddress, string $allowedIpAddresses): bool + { + foreach (explode(',', $allowedIpAddresses) as $allowedIp) { + if ($this->ipMatch($ipAddress, $allowedIp)) { + return true; + } + } + return false; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $reverseProxy = $this->getProperty('reverseProxy', ''); + if ($reverseProxy) { + $ipAddress = array_pop(explode(',', $request->getHeader('X-Forwarded-For'))); + } elseif (isset($_SERVER['REMOTE_ADDR'])) { + $ipAddress = $_SERVER['REMOTE_ADDR']; + } else { + $ipAddress = '127.0.0.1'; + } + $allowedIpAddresses = $this->getProperty('allowedIpAddresses', ''); + if (!$this->isIpAllowed($ipAddress, $allowedIpAddresses)) { + $response = $this->responder->error(ErrorCode::TEMPORARY_OR_PERMANENTLY_BLOCKED, ''); + } else { + $response = $next->handle($request); + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/IpAddressMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + + class IpAddressMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function callHandler($record, string $operation, ReflectedTable $table) /*: object */ + { + $context = (array) $record; + $columnNames = $this->getProperty('columns', ''); + if ($columnNames) { + foreach (explode(',', $columnNames) as $columnName) { + if ($table->hasColumn($columnName)) { + if ($operation == 'create') { + $context[$columnName] = $_SERVER['REMOTE_ADDR']; + } else { + unset($context[$columnName]); + } + } + } + } + return (object) $context; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableNames = $this->getProperty('tables', ''); + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$tableNames || in_array($tableName, explode(',', $tableNames))) { + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as &$r) { + $r = $this->callHandler($r, $operation, $table); + } + } else { + $record = $this->callHandler($record, $operation, $table); + } + $request = $request->withParsedBody($record); + } + } + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/JoinLimitsMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + + class JoinLimitsMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + $params = RequestUtils::getParams($request); + if (in_array($operation, ['read', 'list']) && isset($params['join'])) { + $maxDepth = (int) $this->getProperty('depth', '3'); + $maxTables = (int) $this->getProperty('tables', '10'); + $maxRecords = (int) $this->getProperty('records', '1000'); + $tableCount = 0; + $joinPaths = array(); + for ($i = 0; $i < count($params['join']); $i++) { + $joinPath = array(); + $tables = explode(',', $params['join'][$i]); + for ($depth = 0; $depth < min($maxDepth, count($tables)); $depth++) { + array_push($joinPath, $tables[$depth]); + $tableCount += 1; + if ($tableCount == $maxTables) { + break; + } + } + array_push($joinPaths, implode(',', $joinPath)); + if ($tableCount == $maxTables) { + break; + } + } + $params['join'] = $joinPaths; + $request = RequestUtils::setParams($request, $params); + VariableStore::set("joinLimits.maxRecords", $maxRecords); + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class JwtAuthMiddleware extends Middleware + { + private function getVerifiedClaims(string $token, int $time, int $leeway, int $ttl, array $secrets, array $requirements): array + { + $algorithms = array( + 'HS256' => 'sha256', + 'HS384' => 'sha384', + 'HS512' => 'sha512', + 'RS256' => 'sha256', + 'RS384' => 'sha384', + 'RS512' => 'sha512', + ); + $token = explode('.', $token); + if (count($token) < 3) { + return array(); + } + $header = json_decode(base64_decode(strtr($token[0], '-_', '+/')), true); + $kid = 0; + if (isset($header['kid'])) { + $kid = $header['kid']; + } + if (!isset($secrets[$kid])) { + return array(); + } + $secret = $secrets[$kid]; + if ($header['typ'] != 'JWT') { + return array(); + } + $algorithm = $header['alg']; + if (!isset($algorithms[$algorithm])) { + return array(); + } + if (!empty($requirements['alg']) && !in_array($algorithm, $requirements['alg'])) { + return array(); + } + $hmac = $algorithms[$algorithm]; + $signature = base64_decode(strtr($token[2], '-_', '+/')); + $data = "$token[0].$token[1]"; + switch ($algorithm[0]) { + case 'H': + $hash = hash_hmac($hmac, $data, $secret, true); + $equals = hash_equals($hash, $signature); + if (!$equals) { + return array(); + } + break; + case 'R': + $equals = openssl_verify($data, $signature, $secret, $hmac) == 1; + if (!$equals) { + return array(); + } + break; + } + $claims = json_decode(base64_decode(strtr($token[1], '-_', '+/')), true); + if (!$claims) { + return array(); + } + foreach ($requirements as $field => $values) { + if (!empty($values)) { + if ($field != 'alg') { + if (!isset($claims[$field]) || !in_array($claims[$field], $values)) { + return array(); + } + } + } + } + if (isset($claims['nbf']) && $time + $leeway < $claims['nbf']) { + return array(); + } + if (isset($claims['iat']) && $time + $leeway < $claims['iat']) { + return array(); + } + if (isset($claims['exp']) && $time - $leeway > $claims['exp']) { + return array(); + } + if (isset($claims['iat']) && !isset($claims['exp'])) { + if ($time - $leeway > $claims['iat'] + $ttl) { + return array(); + } + } + return $claims; + } + + private function getClaims(string $token): array + { + $time = (int) $this->getProperty('time', time()); + $leeway = (int) $this->getProperty('leeway', '5'); + $ttl = (int) $this->getProperty('ttl', '30'); + $secrets = $this->getMapProperty('secrets', ''); + if (!$secrets) { + $secrets = [$this->getProperty('secret', '')]; + } + $requirements = array( + 'alg' => $this->getArrayProperty('algorithms', ''), + 'aud' => $this->getArrayProperty('audiences', ''), + 'iss' => $this->getArrayProperty('issuers', ''), + ); + return $this->getVerifiedClaims($token, $time, $leeway, $ttl, $secrets, $requirements); + } + + private function getAuthorizationToken(ServerRequestInterface $request): string + { + $headerName = $this->getProperty('header', 'X-Authorization'); + $headerValue = RequestUtils::getHeader($request, $headerName); + $parts = explode(' ', trim($headerValue), 2); + if (count($parts) != 2) { + return ''; + } + if ($parts[0] != 'Bearer') { + return ''; + } + return $parts[1]; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $token = $this->getAuthorizationToken($request); + if ($token) { + $claims = $this->getClaims($token); + $_SESSION['claims'] = $claims; + if (empty($claims)) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, 'JWT'); + } + if (!headers_sent()) { + session_regenerate_id(); + } + } + if (empty($_SESSION['claims'])) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/MultiTenancyMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\Condition\Condition; + use Tqdev\PhpCrudApi\Record\Condition\NoCondition; + use Tqdev\PhpCrudApi\RequestUtils; + + class MultiTenancyMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function getCondition(string $tableName, array $pairs): Condition + { + $condition = new NoCondition(); + $table = $this->reflection->getTable($tableName); + foreach ($pairs as $k => $v) { + $condition = $condition->_and(new ColumnCondition($table->getColumn($k), 'eq', $v)); + } + return $condition; + } + + private function getPairs($handler, string $operation, string $tableName): array + { + $result = array(); + $pairs = call_user_func($handler, $operation, $tableName) ?: []; + $table = $this->reflection->getTable($tableName); + foreach ($pairs as $k => $v) { + if ($table->hasColumn($k)) { + $result[$k] = $v; + } + } + return $result; + } + + private function handleRecord(ServerRequestInterface $request, string $operation, array $pairs): ServerRequestInterface + { + $record = $request->getParsedBody(); + if ($record === null) { + return $request; + } + $multi = is_array($record); + $records = $multi ? $record : [$record]; + foreach ($records as &$record) { + foreach ($pairs as $column => $value) { + if ($operation == 'create') { + $record->$column = $value; + } else { + if (isset($record->$column)) { + unset($record->$column); + } + } + } + } + return $request->withParsedBody($multi ? $records : $records[0]); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $path = RequestUtils::getPathSegment($request, 1); + if ($path == 'records') { + $operation = RequestUtils::getOperation($request); + $tableNames = RequestUtils::getTableNames($request, $this->reflection); + foreach ($tableNames as $i => $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $pairs = $this->getPairs($handler, $operation, $tableName); + if ($i == 0) { + if (in_array($operation, ['create', 'update', 'increment'])) { + $request = $this->handleRecord($request, $operation, $pairs); + } + } + $condition = $this->getCondition($tableName, $pairs); + VariableStore::set("multiTenancy.conditions.$tableName", $condition); + } + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/PageLimitsMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class PageLimitsMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if ($operation == 'list') { + $params = RequestUtils::getParams($request); + $maxPage = (int) $this->getProperty('pages', '100'); + if (isset($params['page']) && $params['page'] && $maxPage > 0) { + if (strpos($params['page'][0], ',') === false) { + $page = $params['page'][0]; + } else { + list($page, $size) = explode(',', $params['page'][0], 2); + } + if ($page > $maxPage) { + return $this->responder->error(ErrorCode::PAGINATION_FORBIDDEN, ''); + } + } + $maxSize = (int) $this->getProperty('records', '1000'); + if (!isset($params['size']) || !$params['size'] && $maxSize > 0) { + $params['size'] = array($maxSize); + } else { + $params['size'] = array(min($params['size'][0], $maxSize)); + } + $request = RequestUtils::setParams($request, $params); + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/ReconnectMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + + class ReconnectMiddleware extends Middleware + { + private $reflection; + private $db; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection, GenericDB $db) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + $this->db = $db; + } + + private function getDriver(): string + { + $driverHandler = $this->getProperty('driverHandler', ''); + if ($driverHandler) { + return call_user_func($driverHandler); + } + return ''; + } + + private function getAddress(): string + { + $addressHandler = $this->getProperty('addressHandler', ''); + if ($addressHandler) { + return call_user_func($addressHandler); + } + return ''; + } + + private function getPort(): int + { + $portHandler = $this->getProperty('portHandler', ''); + if ($portHandler) { + return call_user_func($portHandler); + } + return 0; + } + + private function getDatabase(): string + { + $databaseHandler = $this->getProperty('databaseHandler', ''); + if ($databaseHandler) { + return call_user_func($databaseHandler); + } + return ''; + } + + private function getTables(): array + { + $tablesHandler = $this->getProperty('tablesHandler', ''); + if ($tablesHandler) { + return call_user_func($tablesHandler); + } + return []; + } + + private function getUsername(): string + { + $usernameHandler = $this->getProperty('usernameHandler', ''); + if ($usernameHandler) { + return call_user_func($usernameHandler); + } + return ''; + } + + private function getPassword(): string + { + $passwordHandler = $this->getProperty('passwordHandler', ''); + if ($passwordHandler) { + return call_user_func($passwordHandler); + } + return ''; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $driver = $this->getDriver(); + $address = $this->getAddress(); + $port = $this->getPort(); + $database = $this->getDatabase(); + $tables = $this->getTables(); + $username = $this->getUsername(); + $password = $this->getPassword(); + if ($driver || $address || $port || $database || $tables || $username || $password) { + $this->db->reconstruct($driver, $address, $port, $database, $tables, $username, $password); + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + + class SanitationMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function callHandler($handler, $record, string $operation, ReflectedTable $table) /*: object */ + { + $context = (array) $record; + $tableName = $table->getName(); + foreach ($context as $columnName => &$value) { + if ($table->hasColumn($columnName)) { + $column = $table->getColumn($columnName); + $value = call_user_func($handler, $operation, $tableName, $column->serialize(), $value); + $value = $this->sanitizeType($table, $column, $value); + } + } + return (object) $context; + } + + private function sanitizeType(ReflectedTable $table, ReflectedColumn $column, $value) + { + $tables = $this->getArrayProperty('tables', 'all'); + $types = $this->getArrayProperty('types', 'all'); + if ( + (in_array('all', $tables) || in_array($table->getName(), $tables)) && + (in_array('all', $types) || in_array($column->getType(), $types)) + ) { + if (is_null($value)) { + return $value; + } + if (is_string($value)) { + $newValue = null; + switch ($column->getType()) { + case 'integer': + case 'bigint': + $newValue = filter_var(trim($value), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + break; + case 'decimal': + $newValue = filter_var(trim($value), FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + if (is_float($newValue)) { + $newValue = number_format($newValue, $column->getScale(), '.', ''); + } + break; + case 'float': + case 'double': + $newValue = filter_var(trim($value), FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + break; + case 'boolean': + $newValue = filter_var(trim($value), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + break; + case 'date': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('Y-m-d', $time); + } + break; + case 'time': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('H:i:s', $time); + } + break; + case 'timestamp': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('Y-m-d H:i:s', $time); + } + break; + case 'blob': + case 'varbinary': + // allow base64url format + $newValue = strtr(trim($value), '-_', '+/'); + break; + case 'clob': + case 'varchar': + $newValue = $value; + break; + case 'geometry': + $newValue = trim($value); + break; + } + if (!is_null($newValue)) { + $value = $newValue; + } + } else { + switch ($column->getType()) { + case 'integer': + case 'bigint': + if (is_float($value)) { + $value = (int) round($value); + } + break; + case 'decimal': + if (is_float($value) || is_int($value)) { + $value = number_format((float) $value, $column->getScale(), '.', ''); + } + break; + } + } + // post process + } + return $value; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableName = RequestUtils::getPathSegment($request, 2); + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as &$r) { + $r = $this->callHandler($handler, $r, $operation, $table); + } + } else { + $record = $this->callHandler($handler, $record, $operation, $table); + } + $request = $request->withParsedBody($record); + } + } + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/SslRedirectMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\ResponseFactory; + + class SslRedirectMiddleware extends Middleware + { + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + if ($scheme == 'http') { + $uri = $request->getUri(); + $uri = $uri->withScheme('https'); + $response = ResponseFactory::fromStatus(301); + $response = $response->withHeader('Location', $uri->__toString()); + } else { + $response = $next->handle($request); + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class ValidationMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function callHandler($handler, $record, string $operation, ReflectedTable $table) /*: ResponseInterface?*/ + { + $context = (array) $record; + $details = array(); + $tableName = $table->getName(); + foreach ($context as $columnName => $value) { + if ($table->hasColumn($columnName)) { + $column = $table->getColumn($columnName); + $valid = call_user_func($handler, $operation, $tableName, $column->serialize(), $value, $context); + if ($valid === true || $valid === '') { + $valid = $this->validateType($table, $column, $value); + } + if ($valid !== true && $valid !== '') { + $details[$columnName] = $valid; + } + } + } + if (count($details) > 0) { + return $this->responder->error(ErrorCode::INPUT_VALIDATION_FAILED, $tableName, $details); + } + return null; + } + + private function validateType(ReflectedTable $table, ReflectedColumn $column, $value) + { + $tables = $this->getArrayProperty('tables', 'all'); + $types = $this->getArrayProperty('types', 'all'); + if ( + (in_array('all', $tables) || in_array($table->getName(), $tables)) && + (in_array('all', $types) || in_array($column->getType(), $types)) + ) { + if (is_null($value)) { + return ($column->getNullable() ? true : "cannot be null"); + } + if (is_string($value)) { + // check for whitespace + switch ($column->getType()) { + case 'varchar': + case 'clob': + break; + default: + if (strlen(trim($value)) != strlen($value)) { + return 'illegal whitespace'; + } + break; + } + // try to parse + switch ($column->getType()) { + case 'integer': + case 'bigint': + if ( + filter_var($value, FILTER_SANITIZE_NUMBER_INT) !== $value || + filter_var($value, FILTER_VALIDATE_INT) === false + ) { + return 'invalid integer'; + } + break; + case 'decimal': + if (strpos($value, '.') !== false) { + list($whole, $decimals) = explode('.', ltrim($value, '-'), 2); + } else { + list($whole, $decimals) = array(ltrim($value, '-'), ''); + } + if (strlen($whole) > 0 && !ctype_digit($whole)) { + return 'invalid decimal'; + } + if (strlen($decimals) > 0 && !ctype_digit($decimals)) { + return 'invalid decimal'; + } + if (strlen($whole) > $column->getPrecision() - $column->getScale()) { + return 'decimal too large'; + } + if (strlen($decimals) > $column->getScale()) { + return 'decimal too precise'; + } + break; + case 'float': + case 'double': + if ( + filter_var($value, FILTER_SANITIZE_NUMBER_FLOAT) !== $value || + filter_var($value, FILTER_VALIDATE_FLOAT) === false + ) { + return 'invalid float'; + } + break; + case 'boolean': + if (!in_array(strtolower($value), array('true', 'false'))) { + return 'invalid boolean'; + } + break; + case 'date': + if (date_create_from_format('Y-m-d', $value) === false) { + return 'invalid date'; + } + break; + case 'time': + if (date_create_from_format('H:i:s', $value) === false) { + return 'invalid time'; + } + break; + case 'timestamp': + if (date_create_from_format('Y-m-d H:i:s', $value) === false) { + return 'invalid timestamp'; + } + break; + case 'clob': + case 'varchar': + if ($column->hasLength() && mb_strlen($value, 'UTF-8') > $column->getLength()) { + return 'string too long'; + } + break; + case 'blob': + case 'varbinary': + if (base64_decode($value, true) === false) { + return 'invalid base64'; + } + if ($column->hasLength() && strlen(base64_decode($value)) > $column->getLength()) { + return 'string too long'; + } + break; + case 'geometry': + // no checks yet + break; + } + } else { // check non-string types + switch ($column->getType()) { + case 'integer': + case 'bigint': + if (!is_int($value)) { + return 'invalid integer'; + } + break; + case 'float': + case 'double': + if (!is_float($value) && !is_int($value)) { + return 'invalid float'; + } + break; + case 'boolean': + if (!is_bool($value) && ($value !== 0) && ($value !== 1)) { + return 'invalid boolean'; + } + break; + default: + return 'invalid ' . $column->getType(); + } + } + // extra checks + switch ($column->getType()) { + case 'integer': // 4 byte signed + $value = filter_var($value, FILTER_VALIDATE_INT); + if ($value > 2147483647 || $value < -2147483648) { + return 'invalid integer'; + } + break; + } + } + return (true); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableName = RequestUtils::getPathSegment($request, 2); + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as $r) { + $response = $this->callHandler($handler, $r, $operation, $table); + if ($response !== null) { + return $response; + } + } + } else { + $response = $this->callHandler($handler, $record, $operation, $table); + if ($response !== null) { + return $response; + } + } + } + } + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/XmlMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + use Tqdev\PhpCrudApi\ResponseFactory; + + class XmlMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function json2xml($json, $types = 'null,boolean,number,string,object,array') + { + $a = json_decode($json); + $d = new \DOMDocument(); + $c = $d->createElement("root"); + $d->appendChild($c); + $t = function ($v) { + $type = gettype($v); + switch ($type) { + case 'integer': + return 'number'; + case 'double': + return 'number'; + default: + return strtolower($type); + } + }; + $ts = explode(',', $types); + $f = function ($f, $c, $a, $s = false) use ($t, $d, $ts) { + if (in_array($t($a), $ts)) { + $c->setAttribute('type', $t($a)); + } + if ($t($a) != 'array' && $t($a) != 'object') { + if ($t($a) == 'boolean') { + $c->appendChild($d->createTextNode($a ? 'true' : 'false')); + } else { + $c->appendChild($d->createTextNode($a)); + } + } else { + foreach ($a as $k => $v) { + if ($k == '__type' && $t($a) == 'object') { + $c->setAttribute('__type', $v); + } else { + if ($t($v) == 'object') { + $ch = $c->appendChild($d->createElementNS(null, $s ? 'item' : $k)); + $f($f, $ch, $v); + } else if ($t($v) == 'array') { + $ch = $c->appendChild($d->createElementNS(null, $s ? 'item' : $k)); + $f($f, $ch, $v, true); + } else { + $va = $d->createElementNS(null, $s ? 'item' : $k); + if ($t($v) == 'boolean') { + $va->appendChild($d->createTextNode($v ? 'true' : 'false')); + } else { + $va->appendChild($d->createTextNode($v)); + } + $ch = $c->appendChild($va); + if (in_array($t($v), $ts)) { + $ch->setAttribute('type', $t($v)); + } + } + } + } + } + }; + $f($f, $c, $a, $t($a) == 'array'); + return $d->saveXML($d->documentElement); + } + + private function xml2json($xml) + { + $a = @dom_import_simplexml(simplexml_load_string($xml)); + if (!$a) { + return null; + } + $t = function ($v) { + $t = $v->getAttribute('type'); + $txt = $v->firstChild->nodeType == XML_TEXT_NODE; + return $t ?: ($txt ? 'string' : 'object'); + }; + $f = function ($f, $a) use ($t) { + $c = null; + if ($t($a) == 'null') { + $c = null; + } else if ($t($a) == 'boolean') { + $b = substr(strtolower($a->textContent), 0, 1); + $c = in_array($b, array('1', 't')); + } else if ($t($a) == 'number') { + $c = $a->textContent + 0; + } else if ($t($a) == 'string') { + $c = $a->textContent; + } else if ($t($a) == 'object') { + $c = array(); + if ($a->getAttribute('__type')) { + $c['__type'] = $a->getAttribute('__type'); + } + for ($i = 0; $i < $a->childNodes->length; $i++) { + $v = $a->childNodes[$i]; + $c[$v->nodeName] = $f($f, $v); + } + $c = (object) $c; + } else if ($t($a) == 'array') { + $c = array(); + for ($i = 0; $i < $a->childNodes->length; $i++) { + $v = $a->childNodes[$i]; + $c[$i] = $f($f, $v); + } + } + return $c; + }; + $c = $f($f, $a); + return json_encode($c); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + parse_str($request->getUri()->getQuery(), $params); + $isXml = isset($params['format']) && $params['format'] == 'xml'; + if ($isXml) { + $body = $request->getBody()->getContents(); + if ($body) { + $json = $this->xml2json($body); + $request = $request->withParsedBody(json_decode($json)); + } + } + $response = $next->handle($request); + if ($isXml) { + $body = $response->getBody()->getContents(); + if ($body) { + $types = implode(',', $this->getArrayProperty('types', 'null,array')); + if ($types == '' || $types == 'all') { + $xml = $this->json2xml($body); + } else { + $xml = $this->json2xml($body, $types); + } + $response = ResponseFactory::fromXml(ResponseFactory::OK, $xml); + } + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/XsrfMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + + class XsrfMiddleware extends Middleware + { + private function getToken(): string + { + $cookieName = $this->getProperty('cookieName', 'XSRF-TOKEN'); + if (isset($_COOKIE[$cookieName])) { + $token = $_COOKIE[$cookieName]; + } else { + $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on'; + $token = bin2hex(random_bytes(8)); + if (!headers_sent()) { + setcookie($cookieName, $token, 0, '', '', $secure); + } + } + return $token; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $token = $this->getToken(); + $method = $request->getMethod(); + $excludeMethods = $this->getArrayProperty('excludeMethods', 'OPTIONS,GET'); + if (!in_array($method, $excludeMethods)) { + $headerName = $this->getProperty('headerName', 'X-XSRF-TOKEN'); + if ($token != $request->getHeader($headerName)) { + return $this->responder->error(ErrorCode::BAD_OR_MISSING_XSRF_TOKEN, ''); + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiBuilder.php +namespace Tqdev\PhpCrudApi\OpenApi { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\OpenApi\OpenApiDefinition; + + class OpenApiBuilder + { + private $openapi; + private $records; + private $columns; + private $builders; + + public function __construct(ReflectionService $reflection, array $base, array $controllers, array $builders) + { + $this->openapi = new OpenApiDefinition($base); + $this->records = in_array('records', $controllers) ? new OpenApiRecordsBuilder($this->openapi, $reflection) : null; + $this->columns = in_array('columns', $controllers) ? new OpenApiColumnsBuilder($this->openapi) : null; + $this->builders = array(); + foreach ($builders as $className) { + $this->builders[] = new $className($this->openapi, $reflection); + } + } + + private function getServerUrl(): string + { + $protocol = @$_SERVER['HTTP_X_FORWARDED_PROTO'] ?: @$_SERVER['REQUEST_SCHEME'] ?: ((isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] == "on") ? "https" : "http"); + $port = @intval($_SERVER['HTTP_X_FORWARDED_PORT']) ?: @intval($_SERVER["SERVER_PORT"]) ?: (($protocol === 'https') ? 443 : 80); + $host = @explode(":", $_SERVER['HTTP_HOST'])[0] ?: @$_SERVER['SERVER_NAME'] ?: @$_SERVER['SERVER_ADDR']; + $port = ($protocol === 'https' && $port === 443) || ($protocol === 'http' && $port === 80) ? '' : ':' . $port; + $path = @trim(substr($_SERVER['REQUEST_URI'], 0, strpos($_SERVER['REQUEST_URI'], '/openapi')), '/'); + return sprintf('%s://%s%s/%s', $protocol, $host, $port, $path); + } + + public function build(): OpenApiDefinition + { + $this->openapi->set("openapi", "3.0.0"); + if (!$this->openapi->has("servers") && isset($_SERVER['REQUEST_URI'])) { + $this->openapi->set("servers|0|url", $this->getServerUrl()); + } + if ($this->records) { + $this->records->build(); + } + if ($this->columns) { + $this->columns->build(); + } + foreach ($this->builders as $builder) { + $builder->build(); + } + return $this->openapi; + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiColumnsBuilder.php +namespace Tqdev\PhpCrudApi\OpenApi { + + use Tqdev\PhpCrudApi\OpenApi\OpenApiDefinition; + + class OpenApiColumnsBuilder + { + private $openapi; + private $operations = [ + 'database' => [ + 'read' => 'get', + ], + 'table' => [ + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', //rename + 'delete' => 'delete', + ], + 'column' => [ + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', + 'delete' => 'delete', + ] + ]; + + public function __construct(OpenApiDefinition $openapi) + { + $this->openapi = $openapi; + } + + public function build() /*: void*/ + { + $this->setPaths(); + $this->openapi->set("components|responses|boolSuccess|description", "boolean indicating success or failure"); + $this->openapi->set("components|responses|boolSuccess|content|application/json|schema|type", "boolean"); + $this->setComponentSchema(); + $this->setComponentResponse(); + $this->setComponentRequestBody(); + $this->setComponentParameters(); + foreach (array_keys($this->operations) as $index => $type) { + $this->setTag($index, $type); + } + } + + private function setPaths() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach ($this->operations[$type] as $operation => $method) { + $parameters = []; + switch ($type) { + case 'database': + $path = '/columns'; + break; + case 'table': + $path = $operation == 'create' ? '/columns' : '/columns/{table}'; + break; + case 'column': + $path = $operation == 'create' ? '/columns/{table}' : '/columns/{table}/{column}'; + break; + } + if (strpos($path, '{table}')) { + $parameters[] = 'table'; + } + if (strpos($path, '{column}')) { + $parameters[] = 'column'; + } + foreach ($parameters as $p => $parameter) { + $this->openapi->set("paths|$path|$method|parameters|$p|\$ref", "#/components/parameters/$parameter"); + } + $operationType = $operation . ucfirst($type); + if (in_array($operation, ['create', 'update'])) { + $this->openapi->set("paths|$path|$method|requestBody|\$ref", "#/components/requestBodies/$operationType"); + } + $this->openapi->set("paths|$path|$method|tags|0", "$type"); + $this->openapi->set("paths|$path|$method|operationId", "$operation" . "_" . "$type"); + if ($operationType == 'updateTable') { + $this->openapi->set("paths|$path|$method|description", "rename table"); + } else { + $this->openapi->set("paths|$path|$method|description", "$operation $type"); + } + switch ($operation) { + case 'read': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operationType"); + break; + case 'create': + case 'update': + case 'delete': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/boolSuccess"); + break; + } + } + } + } + + private function setComponentSchema() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if ($operation == 'delete') { + continue; + } + $operationType = $operation . ucfirst($type); + $prefix = "components|schemas|$operationType"; + $this->openapi->set("$prefix|type", "object"); + switch ($type) { + case 'database': + $this->openapi->set("$prefix|properties|tables|type", 'array'); + $this->openapi->set("$prefix|properties|tables|items|\$ref", "#/components/schemas/readTable"); + break; + case 'table': + if ($operation == 'update') { + $this->openapi->set("$prefix|required", ['name']); + $this->openapi->set("$prefix|properties|name|type", 'string'); + } else { + $this->openapi->set("$prefix|properties|name|type", 'string'); + if ($operation == 'read') { + $this->openapi->set("$prefix|properties|type|type", 'string'); + } + $this->openapi->set("$prefix|properties|columns|type", 'array'); + $this->openapi->set("$prefix|properties|columns|items|\$ref", "#/components/schemas/readColumn"); + } + break; + case 'column': + $this->openapi->set("$prefix|required", ['name', 'type']); + $this->openapi->set("$prefix|properties|name|type", 'string'); + $this->openapi->set("$prefix|properties|type|type", 'string'); + $this->openapi->set("$prefix|properties|length|type", 'integer'); + $this->openapi->set("$prefix|properties|length|format", "int64"); + $this->openapi->set("$prefix|properties|precision|type", 'integer'); + $this->openapi->set("$prefix|properties|precision|format", "int64"); + $this->openapi->set("$prefix|properties|scale|type", 'integer'); + $this->openapi->set("$prefix|properties|scale|format", "int64"); + $this->openapi->set("$prefix|properties|nullable|type", 'boolean'); + $this->openapi->set("$prefix|properties|pk|type", 'boolean'); + $this->openapi->set("$prefix|properties|fk|type", 'string'); + break; + } + } + } + } + + private function setComponentResponse() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if ($operation != 'read') { + continue; + } + $operationType = $operation . ucfirst($type); + $this->openapi->set("components|responses|$operationType|description", "single $type record"); + $this->openapi->set("components|responses|$operationType|content|application/json|schema|\$ref", "#/components/schemas/$operationType"); + } + } + } + + private function setComponentRequestBody() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if (!in_array($operation, ['create', 'update'])) { + continue; + } + $operationType = $operation . ucfirst($type); + $this->openapi->set("components|requestBodies|$operationType|description", "single $type record"); + $this->openapi->set("components|requestBodies|$operationType|content|application/json|schema|\$ref", "#/components/schemas/$operationType"); + } + } + } + + private function setComponentParameters() /*: void*/ + { + $this->openapi->set("components|parameters|table|name", "table"); + $this->openapi->set("components|parameters|table|in", "path"); + $this->openapi->set("components|parameters|table|schema|type", "string"); + $this->openapi->set("components|parameters|table|description", "table name"); + $this->openapi->set("components|parameters|table|required", true); + + $this->openapi->set("components|parameters|column|name", "column"); + $this->openapi->set("components|parameters|column|in", "path"); + $this->openapi->set("components|parameters|column|schema|type", "string"); + $this->openapi->set("components|parameters|column|description", "column name"); + $this->openapi->set("components|parameters|column|required", true); + } + + private function setTag(int $index, string $type) /*: void*/ + { + $this->openapi->set("tags|$index|name", "$type"); + $this->openapi->set("tags|$index|description", "$type operations"); + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiDefinition.php +namespace Tqdev\PhpCrudApi\OpenApi { + + class OpenApiDefinition implements \JsonSerializable + { + private $root; + + public function __construct(array $base) + { + $this->root = $base; + } + + public function set(string $path, $value) /*: void*/ + { + $parts = explode('|', trim($path, '|')); + $current = &$this->root; + while (count($parts) > 0) { + $part = array_shift($parts); + if (!isset($current[$part])) { + $current[$part] = []; + } + $current = &$current[$part]; + } + $current = $value; + } + + public function has(string $path): bool + { + $parts = explode('|', trim($path, '|')); + $current = &$this->root; + while (count($parts) > 0) { + $part = array_shift($parts); + if (!isset($current[$part])) { + return false; + } + $current = &$current[$part]; + } + return true; + } + + public function jsonSerialize() + { + return $this->root; + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiRecordsBuilder.php +namespace Tqdev\PhpCrudApi\OpenApi { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\OpenApi\OpenApiDefinition; + + class OpenApiRecordsBuilder + { + private $openapi; + private $reflection; + private $operations = [ + 'list' => 'get', + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', + 'delete' => 'delete', + 'increment' => 'patch', + ]; + private $types = [ + 'integer' => ['type' => 'integer', 'format' => 'int32'], + 'bigint' => ['type' => 'integer', 'format' => 'int64'], + 'varchar' => ['type' => 'string'], + 'clob' => ['type' => 'string', 'format' => 'large-string'], //custom format + 'varbinary' => ['type' => 'string', 'format' => 'byte'], + 'blob' => ['type' => 'string', 'format' => 'large-byte'], //custom format + 'decimal' => ['type' => 'string', 'format' => 'decimal'], //custom format + 'float' => ['type' => 'number', 'format' => 'float'], + 'double' => ['type' => 'number', 'format' => 'double'], + 'date' => ['type' => 'string', 'format' => 'date'], + 'time' => ['type' => 'string', 'format' => 'time'], //custom format + 'timestamp' => ['type' => 'string', 'format' => 'date-time'], + 'geometry' => ['type' => 'string', 'format' => 'geometry'], //custom format + 'boolean' => ['type' => 'boolean'], + ]; + + public function __construct(OpenApiDefinition $openapi, ReflectionService $reflection) + { + $this->openapi = $openapi; + $this->reflection = $reflection; + } + + private function getAllTableReferences(): array + { + $tableReferences = array(); + foreach ($this->reflection->getTableNames() as $tableName) { + $table = $this->reflection->getTable($tableName); + foreach ($table->getColumnNames() as $columnName) { + $column = $table->getColumn($columnName); + $referencedTableName = $column->getFk(); + if ($referencedTableName) { + if (!isset($tableReferences[$referencedTableName])) { + $tableReferences[$referencedTableName] = array(); + } + $tableReferences[$referencedTableName][] = "$tableName.$columnName"; + } + } + } + return $tableReferences; + } + + public function build() /*: void*/ + { + $tableNames = $this->reflection->getTableNames(); + foreach ($tableNames as $tableName) { + $this->setPath($tableName); + } + $this->openapi->set("components|responses|pk_integer|description", "inserted primary key value (integer)"); + $this->openapi->set("components|responses|pk_integer|content|application/json|schema|type", "integer"); + $this->openapi->set("components|responses|pk_integer|content|application/json|schema|format", "int64"); + $this->openapi->set("components|responses|pk_string|description", "inserted primary key value (string)"); + $this->openapi->set("components|responses|pk_string|content|application/json|schema|type", "string"); + $this->openapi->set("components|responses|pk_string|content|application/json|schema|format", "uuid"); + $this->openapi->set("components|responses|rows_affected|description", "number of rows affected (integer)"); + $this->openapi->set("components|responses|rows_affected|content|application/json|schema|type", "integer"); + $this->openapi->set("components|responses|rows_affected|content|application/json|schema|format", "int64"); + $tableReferences = $this->getAllTableReferences(); + foreach ($tableNames as $tableName) { + $references = isset($tableReferences[$tableName]) ? $tableReferences[$tableName] : array(); + $this->setComponentSchema($tableName, $references); + $this->setComponentResponse($tableName); + $this->setComponentRequestBody($tableName); + } + $this->setComponentParameters(); + foreach ($tableNames as $index => $tableName) { + $this->setTag($index, $tableName); + } + } + + private function isOperationOnTableAllowed(string $operation, string $tableName): bool + { + $tableHandler = VariableStore::get('authorization.tableHandler'); + if (!$tableHandler) { + return true; + } + return (bool) call_user_func($tableHandler, $operation, $tableName); + } + + private function isOperationOnColumnAllowed(string $operation, string $tableName, string $columnName): bool + { + $columnHandler = VariableStore::get('authorization.columnHandler'); + if (!$columnHandler) { + return true; + } + return (bool) call_user_func($columnHandler, $operation, $tableName, $columnName); + } + + private function setPath(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach ($this->operations as $operation => $method) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type != 'table' && $operation != 'list') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + $parameters = []; + if (in_array($operation, ['list', 'create'])) { + $path = sprintf('/records/%s', $tableName); + if ($operation == 'list') { + $parameters = ['filter', 'include', 'exclude', 'order', 'size', 'page', 'join']; + } + } else { + $path = sprintf('/records/%s/{id}', $tableName); + if ($operation == 'read') { + $parameters = ['pk', 'include', 'exclude', 'join']; + } else { + $parameters = ['pk']; + } + } + foreach ($parameters as $p => $parameter) { + $this->openapi->set("paths|$path|$method|parameters|$p|\$ref", "#/components/parameters/$parameter"); + } + if (in_array($operation, ['create', 'update', 'increment'])) { + $this->openapi->set("paths|$path|$method|requestBody|\$ref", "#/components/requestBodies/$operation-" . rawurlencode($tableName)); + } + $this->openapi->set("paths|$path|$method|tags|0", "$tableName"); + $this->openapi->set("paths|$path|$method|operationId", "$operation" . "_" . "$tableName"); + $this->openapi->set("paths|$path|$method|description", "$operation $tableName"); + switch ($operation) { + case 'list': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operation-" . rawurlencode($tableName)); + break; + case 'create': + if ($pk->getType() == 'integer') { + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/pk_integer"); + } else { + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/pk_string"); + } + break; + case 'read': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operation-" . rawurlencode($tableName)); + break; + case 'update': + case 'delete': + case 'increment': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/rows_affected"); + break; + } + } + } + + private function getPattern(ReflectedColumn $column): string + { + switch ($column->getType()) { + case 'integer': + $n = strlen(pow(2, 31)); + return '^-?[0-9]{1,' . $n . '}$'; + case 'bigint': + $n = strlen(pow(2, 63)); + return '^-?[0-9]{1,' . $n . '}$'; + case 'varchar': + $l = $column->getLength(); + return '^.{0,' . $l . '}$'; + case 'clob': + return '^.*$'; + case 'varbinary': + $l = $column->getLength(); + $b = (int) 4 * ceil($l / 3); + return '^[A-Za-z0-9+/]{0,' . $b . '}=*$'; + case 'blob': + return '^[A-Za-z0-9+/]*=*$'; + case 'decimal': + $p = $column->getPrecision(); + $s = $column->getScale(); + return '^-?[0-9]{1,' . ($p - $s) . '}(\.[0-9]{1,' . $s . '})?$'; + case 'float': + return '^-?[0-9]+(\.[0-9]+)?([eE]-?[0-9]+)?$'; + case 'double': + return '^-?[0-9]+(\.[0-9]+)?([eE]-?[0-9]+)?$'; + case 'date': + return '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; + case 'time': + return '^[0-9]{2}:[0-9]{2}:[0-9]{2}$'; + case 'timestamp': + return '^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$'; + return ''; + case 'geometry': + return '^(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON)\s*\(.*$'; + case 'boolean': + return '^(true|false)$'; + } + return ''; + } + + private function setComponentSchema(string $tableName, array $references) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach ($this->operations as $operation => $method) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type == 'view' && !in_array($operation, array('read', 'list'))) { + continue; + } + if ($type == 'view' && !$pkName && $operation == 'read') { + continue; + } + if ($operation == 'delete') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + if ($operation == 'list') { + $this->openapi->set("components|schemas|$operation-$tableName|type", "object"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|results|type", "integer"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|results|format", "int64"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|records|type", "array"); + $prefix = "components|schemas|$operation-$tableName|properties|records|items"; + } else { + $prefix = "components|schemas|$operation-$tableName"; + } + $this->openapi->set("$prefix|type", "object"); + foreach ($table->getColumnNames() as $columnName) { + if (!$this->isOperationOnColumnAllowed($operation, $tableName, $columnName)) { + continue; + } + $column = $table->getColumn($columnName); + $properties = $this->types[$column->getType()]; + $properties['maxLength'] = $column->hasLength() ? $column->getLength() : 0; + $properties['nullable'] = $column->getNullable(); + $properties['pattern'] = $this->getPattern($column); + foreach ($properties as $key => $value) { + if ($value) { + $this->openapi->set("$prefix|properties|$columnName|$key", $value); + } + } + if ($column->getPk()) { + $this->openapi->set("$prefix|properties|$columnName|x-primary-key", true); + $this->openapi->set("$prefix|properties|$columnName|x-referenced", $references); + } + $fk = $column->getFk(); + if ($fk) { + $this->openapi->set("$prefix|properties|$columnName|x-references", $fk); + } + } + } + } + + private function setComponentResponse(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach (['list', 'read'] as $operation) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type != 'table' && $operation != 'list') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + if ($operation == 'list') { + $this->openapi->set("components|responses|$operation-$tableName|description", "list of $tableName records"); + } else { + $this->openapi->set("components|responses|$operation-$tableName|description", "single $tableName record"); + } + $this->openapi->set("components|responses|$operation-$tableName|content|application/json|schema|\$ref", "#/components/schemas/$operation-" . rawurlencode($tableName)); + } + } + + private function setComponentRequestBody(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + if ($pkName && $type == 'table') { + foreach (['create', 'update', 'increment'] as $operation) { + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + $this->openapi->set("components|requestBodies|$operation-$tableName|description", "single $tableName record"); + $this->openapi->set("components|requestBodies|$operation-$tableName|content|application/json|schema|\$ref", "#/components/schemas/$operation-" . rawurlencode($tableName)); + } + } + } + + private function setComponentParameters() /*: void*/ + { + $this->openapi->set("components|parameters|pk|name", "id"); + $this->openapi->set("components|parameters|pk|in", "path"); + $this->openapi->set("components|parameters|pk|schema|type", "string"); + $this->openapi->set("components|parameters|pk|description", "primary key value"); + $this->openapi->set("components|parameters|pk|required", true); + + $this->openapi->set("components|parameters|filter|name", "filter"); + $this->openapi->set("components|parameters|filter|in", "query"); + $this->openapi->set("components|parameters|filter|schema|type", "array"); + $this->openapi->set("components|parameters|filter|schema|items|type", "string"); + $this->openapi->set("components|parameters|filter|description", "Filters to be applied. Each filter consists of a column, an operator and a value (comma separated). Example: id,eq,1"); + $this->openapi->set("components|parameters|filter|required", false); + + $this->openapi->set("components|parameters|include|name", "include"); + $this->openapi->set("components|parameters|include|in", "query"); + $this->openapi->set("components|parameters|include|schema|type", "string"); + $this->openapi->set("components|parameters|include|description", "Columns you want to include in the output (comma separated). Example: posts.*,categories.name"); + $this->openapi->set("components|parameters|include|required", false); + + $this->openapi->set("components|parameters|exclude|name", "exclude"); + $this->openapi->set("components|parameters|exclude|in", "query"); + $this->openapi->set("components|parameters|exclude|schema|type", "string"); + $this->openapi->set("components|parameters|exclude|description", "Columns you want to exclude from the output (comma separated). Example: posts.content"); + $this->openapi->set("components|parameters|exclude|required", false); + + $this->openapi->set("components|parameters|order|name", "order"); + $this->openapi->set("components|parameters|order|in", "query"); + $this->openapi->set("components|parameters|order|schema|type", "array"); + $this->openapi->set("components|parameters|order|schema|items|type", "string"); + $this->openapi->set("components|parameters|order|description", "Column you want to sort on and the sort direction (comma separated). Example: id,desc"); + $this->openapi->set("components|parameters|order|required", false); + + $this->openapi->set("components|parameters|size|name", "size"); + $this->openapi->set("components|parameters|size|in", "query"); + $this->openapi->set("components|parameters|size|schema|type", "string"); + $this->openapi->set("components|parameters|size|description", "Maximum number of results (for top lists). Example: 10"); + $this->openapi->set("components|parameters|size|required", false); + + $this->openapi->set("components|parameters|page|name", "page"); + $this->openapi->set("components|parameters|page|in", "query"); + $this->openapi->set("components|parameters|page|schema|type", "string"); + $this->openapi->set("components|parameters|page|description", "Page number and page size (comma separated). Example: 1,10"); + $this->openapi->set("components|parameters|page|required", false); + + $this->openapi->set("components|parameters|join|name", "join"); + $this->openapi->set("components|parameters|join|in", "query"); + $this->openapi->set("components|parameters|join|schema|type", "array"); + $this->openapi->set("components|parameters|join|schema|items|type", "string"); + $this->openapi->set("components|parameters|join|description", "Paths (comma separated) to related entities that you want to include. Example: comments,users"); + $this->openapi->set("components|parameters|join|required", false); + } + + private function setTag(int $index, string $tableName) /*: void*/ + { + $this->openapi->set("tags|$index|name", "$tableName"); + $this->openapi->set("tags|$index|description", "$tableName operations"); + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiService.php +namespace Tqdev\PhpCrudApi\OpenApi { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\OpenApi\OpenApiBuilder; + + class OpenApiService + { + private $builder; + + public function __construct(ReflectionService $reflection, array $base, array $controllers, array $customBuilders) + { + $this->builder = new OpenApiBuilder($reflection, $base, $controllers, $customBuilders); + } + + public function get(): OpenApiDefinition + { + return $this->builder->build(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/AndCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class AndCondition extends Condition + { + private $conditions; + + public function __construct(Condition $condition1, Condition $condition2) + { + $this->conditions = [$condition1, $condition2]; + } + + public function _and(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + $this->conditions[] = $condition; + return $this; + } + + public function getConditions(): array + { + return $this->conditions; + } + + public static function fromArray(array $conditions): Condition + { + $condition = new NoCondition(); + foreach ($conditions as $c) { + $condition = $condition->_and($c); + } + return $condition; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/ColumnCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + + class ColumnCondition extends Condition + { + private $column; + private $operator; + private $value; + + public function __construct(ReflectedColumn $column, string $operator, string $value) + { + $this->column = $column; + $this->operator = $operator; + $this->value = $value; + } + + public function getColumn(): ReflectedColumn + { + return $this->column; + } + + public function getOperator(): string + { + return $this->operator; + } + + public function getValue(): string + { + return $this->value; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/Condition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + abstract class Condition + { + public function _and(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + return new AndCondition($this, $condition); + } + + public function _or(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + return new OrCondition($this, $condition); + } + + public function _not(): Condition + { + return new NotCondition($this); + } + + public static function fromString(ReflectedTable $table, string $value): Condition + { + $condition = new NoCondition(); + $parts = explode(',', $value, 3); + if (count($parts) < 2) { + return $condition; + } + if (count($parts) < 3) { + $parts[2] = ''; + } + $field = $table->getColumn($parts[0]); + $command = $parts[1]; + $negate = false; + $spatial = false; + if (strlen($command) > 2) { + if (substr($command, 0, 1) == 'n') { + $negate = true; + $command = substr($command, 1); + } + if (substr($command, 0, 1) == 's') { + $spatial = true; + $command = substr($command, 1); + } + } + if ($spatial) { + if (in_array($command, ['co', 'cr', 'di', 'eq', 'in', 'ov', 'to', 'wi', 'ic', 'is', 'iv'])) { + $condition = new SpatialCondition($field, $command, $parts[2]); + } + } else { + if (in_array($command, ['cs', 'sw', 'ew', 'eq', 'lt', 'le', 'ge', 'gt', 'bt', 'in', 'is'])) { + $condition = new ColumnCondition($field, $command, $parts[2]); + } + } + if ($negate) { + $condition = $condition->_not(); + } + return $condition; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/NoCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class NoCondition extends Condition + { + public function _and(Condition $condition): Condition + { + return $condition; + } + + public function _or(Condition $condition): Condition + { + return $condition; + } + + public function _not(): Condition + { + return $this; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/NotCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class NotCondition extends Condition + { + private $condition; + + public function __construct(Condition $condition) + { + $this->condition = $condition; + } + + public function getCondition(): Condition + { + return $this->condition; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/OrCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class OrCondition extends Condition + { + private $conditions; + + public function __construct(Condition $condition1, Condition $condition2) + { + $this->conditions = [$condition1, $condition2]; + } + + public function _or(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + $this->conditions[] = $condition; + return $this; + } + + public function getConditions(): array + { + return $this->conditions; + } + + public static function fromArray(array $conditions): Condition + { + $condition = new NoCondition(); + foreach ($conditions as $c) { + $condition = $condition->_or($c); + } + return $condition; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/SpatialCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class SpatialCondition extends ColumnCondition + { + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Document/ErrorDocument.php +namespace Tqdev\PhpCrudApi\Record\Document { + + use Tqdev\PhpCrudApi\Record\ErrorCode; + + class ErrorDocument implements \JsonSerializable + { + public $code; + public $message; + public $details; + + public function __construct(ErrorCode $errorCode, string $argument, $details) + { + $this->code = $errorCode->getCode(); + $this->message = $errorCode->getMessage($argument); + $this->details = $details; + } + + public function getCode(): int + { + return $this->code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function serialize() + { + return [ + 'code' => $this->code, + 'message' => $this->message, + 'details' => $this->details, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Document/ListDocument.php +namespace Tqdev\PhpCrudApi\Record\Document { + + class ListDocument implements \JsonSerializable + { + private $records; + + private $results; + + public function __construct(array $records, int $results) + { + $this->records = $records; + $this->results = $results; + } + + public function getRecords(): array + { + return $this->records; + } + + public function getResults(): int + { + return $this->results; + } + + public function serialize() + { + return [ + 'records' => $this->records, + 'results' => $this->results, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize(), function ($v) { + return $v !== 0; + }); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/ColumnIncluder.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + class ColumnIncluder + { + private function isMandatory(string $tableName, string $columnName, array $params): bool + { + return isset($params['mandatory']) && in_array($tableName . "." . $columnName, $params['mandatory']); + } + + private function select( + string $tableName, + bool $primaryTable, + array $params, + string $paramName, + array $columnNames, + bool $include + ): array { + if (!isset($params[$paramName])) { + return $columnNames; + } + $columns = array(); + foreach (explode(',', $params[$paramName][0]) as $columnName) { + $columns[$columnName] = true; + } + $result = array(); + foreach ($columnNames as $columnName) { + $match = isset($columns['*.*']); + if (!$match) { + $match = isset($columns[$tableName . '.*']) || isset($columns[$tableName . '.' . $columnName]); + } + if ($primaryTable && !$match) { + $match = isset($columns['*']) || isset($columns[$columnName]); + } + if ($match) { + if ($include || $this->isMandatory($tableName, $columnName, $params)) { + $result[] = $columnName; + } + } else { + if (!$include || $this->isMandatory($tableName, $columnName, $params)) { + $result[] = $columnName; + } + } + } + return $result; + } + + public function getNames(ReflectedTable $table, bool $primaryTable, array $params): array + { + $tableName = $table->getName(); + $results = $table->getColumnNames(); + $results = $this->select($tableName, $primaryTable, $params, 'include', $results, true); + $results = $this->select($tableName, $primaryTable, $params, 'exclude', $results, false); + return $results; + } + + public function getValues(ReflectedTable $table, bool $primaryTable, /* object */ $record, array $params): array + { + $results = array(); + $columnNames = $this->getNames($table, $primaryTable, $params); + foreach ($columnNames as $columnName) { + if (property_exists($record, $columnName)) { + $results[$columnName] = $record->$columnName; + } + } + return $results; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/ErrorCode.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\ResponseFactory; + + class ErrorCode + { + private $code; + private $message; + private $status; + + const ERROR_NOT_FOUND = 9999; + const ROUTE_NOT_FOUND = 1000; + const TABLE_NOT_FOUND = 1001; + const ARGUMENT_COUNT_MISMATCH = 1002; + const RECORD_NOT_FOUND = 1003; + const ORIGIN_FORBIDDEN = 1004; + const COLUMN_NOT_FOUND = 1005; + const TABLE_ALREADY_EXISTS = 1006; + const COLUMN_ALREADY_EXISTS = 1007; + const HTTP_MESSAGE_NOT_READABLE = 1008; + const DUPLICATE_KEY_EXCEPTION = 1009; + const DATA_INTEGRITY_VIOLATION = 1010; + const AUTHENTICATION_REQUIRED = 1011; + const AUTHENTICATION_FAILED = 1012; + const INPUT_VALIDATION_FAILED = 1013; + const OPERATION_FORBIDDEN = 1014; + const OPERATION_NOT_SUPPORTED = 1015; + const TEMPORARY_OR_PERMANENTLY_BLOCKED = 1016; + const BAD_OR_MISSING_XSRF_TOKEN = 1017; + const ONLY_AJAX_REQUESTS_ALLOWED = 1018; + const PAGINATION_FORBIDDEN = 1019; + const USER_ALREADY_EXIST = 1020; + const PASSWORD_TOO_SHORT = 1021; + + private $values = [ + 9999 => ["%s", ResponseFactory::INTERNAL_SERVER_ERROR], + 1000 => ["Route '%s' not found", ResponseFactory::NOT_FOUND], + 1001 => ["Table '%s' not found", ResponseFactory::NOT_FOUND], + 1002 => ["Argument count mismatch in '%s'", ResponseFactory::UNPROCESSABLE_ENTITY], + 1003 => ["Record '%s' not found", ResponseFactory::NOT_FOUND], + 1004 => ["Origin '%s' is forbidden", ResponseFactory::FORBIDDEN], + 1005 => ["Column '%s' not found", ResponseFactory::NOT_FOUND], + 1006 => ["Table '%s' already exists", ResponseFactory::CONFLICT], + 1007 => ["Column '%s' already exists", ResponseFactory::CONFLICT], + 1008 => ["Cannot read HTTP message", ResponseFactory::UNPROCESSABLE_ENTITY], + 1009 => ["Duplicate key exception", ResponseFactory::CONFLICT], + 1010 => ["Data integrity violation", ResponseFactory::CONFLICT], + 1011 => ["Authentication required", ResponseFactory::UNAUTHORIZED], + 1012 => ["Authentication failed for '%s'", ResponseFactory::FORBIDDEN], + 1013 => ["Input validation failed for '%s'", ResponseFactory::UNPROCESSABLE_ENTITY], + 1014 => ["Operation forbidden", ResponseFactory::FORBIDDEN], + 1015 => ["Operation '%s' not supported", ResponseFactory::METHOD_NOT_ALLOWED], + 1016 => ["Temporary or permanently blocked", ResponseFactory::FORBIDDEN], + 1017 => ["Bad or missing XSRF token", ResponseFactory::FORBIDDEN], + 1018 => ["Only AJAX requests allowed for '%s'", ResponseFactory::FORBIDDEN], + 1019 => ["Pagination forbidden", ResponseFactory::FORBIDDEN], + 1020 => ["User '%s' already exists", ResponseFactory::CONFLICT], + 1021 => ["Password too short (<%d characters)", ResponseFactory::UNPROCESSABLE_ENTITY], + ]; + + public function __construct(int $code) + { + if (!isset($this->values[$code])) { + $code = 9999; + } + $this->code = $code; + $this->message = $this->values[$code][0]; + $this->status = $this->values[$code][1]; + } + + public function getCode(): int + { + return $this->code; + } + + public function getMessage(string $argument): string + { + return sprintf($this->message, $argument); + } + + public function getStatus(): int + { + return $this->status; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/FilterInfo.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Record\Condition\AndCondition; + use Tqdev\PhpCrudApi\Record\Condition\Condition; + use Tqdev\PhpCrudApi\Record\Condition\NoCondition; + use Tqdev\PhpCrudApi\Record\Condition\OrCondition; + + class FilterInfo + { + private function getConditionsAsPathTree(ReflectedTable $table, array $params): PathTree + { + $conditions = new PathTree(); + foreach ($params as $key => $filters) { + if (substr($key, 0, 6) == 'filter') { + preg_match_all('/\d+|\D+/', substr($key, 6), $matches); + $path = $matches[0]; + foreach ($filters as $filter) { + $condition = Condition::fromString($table, $filter); + if (($condition instanceof NoCondition) == false) { + $conditions->put($path, $condition); + } + } + } + } + return $conditions; + } + + private function combinePathTreeOfConditions(PathTree $tree): Condition + { + $andConditions = $tree->getValues(); + $and = AndCondition::fromArray($andConditions); + $orConditions = []; + foreach ($tree->getKeys() as $p) { + $orConditions[] = $this->combinePathTreeOfConditions($tree->get($p)); + } + $or = OrCondition::fromArray($orConditions); + return $and->_and($or); + } + + public function getCombinedConditions(ReflectedTable $table, array $params): Condition + { + return $this->combinePathTreeOfConditions($this->getConditionsAsPathTree($table, $params)); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/HabtmValues.php +namespace Tqdev\PhpCrudApi\Record { + + class HabtmValues + { + public $pkValues; + public $fkValues; + + public function __construct(array $pkValues, array $fkValues) + { + $this->pkValues = $pkValues; + $this->fkValues = $fkValues; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/OrderingInfo.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + class OrderingInfo + { + public function getColumnOrdering(ReflectedTable $table, array $params): array + { + $fields = array(); + if (isset($params['order'])) { + foreach ($params['order'] as $order) { + $parts = explode(',', $order, 3); + $columnName = $parts[0]; + if (!$table->hasColumn($columnName)) { + continue; + } + $ascending = 'ASC'; + if (count($parts) > 1) { + if (substr(strtoupper($parts[1]), 0, 4) == "DESC") { + $ascending = 'DESC'; + } + } + $fields[] = [$columnName, $ascending]; + } + } + if (count($fields) == 0) { + return $this->getDefaultColumnOrdering($table); + } + return $fields; + } + + public function getDefaultColumnOrdering(ReflectedTable $table): array + { + $fields = array(); + $pk = $table->getPk(); + if ($pk) { + $fields[] = [$pk->getName(), 'ASC']; + } else { + foreach ($table->getColumnNames() as $columnName) { + $fields[] = [$columnName, 'ASC']; + } + } + return $fields; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/PaginationInfo.php +namespace Tqdev\PhpCrudApi\Record { + + class PaginationInfo + { + public $DEFAULT_PAGE_SIZE = 20; + + public function hasPage(array $params): bool + { + return isset($params['page']); + } + + public function getPageOffset(array $params): int + { + $offset = 0; + $pageSize = $this->getPageSize($params); + if (isset($params['page'])) { + foreach ($params['page'] as $page) { + $parts = explode(',', $page, 2); + $page = intval($parts[0]) - 1; + $offset = $page * $pageSize; + } + } + return $offset; + } + + private function getPageSize(array $params): int + { + $pageSize = $this->DEFAULT_PAGE_SIZE; + if (isset($params['page'])) { + foreach ($params['page'] as $page) { + $parts = explode(',', $page, 2); + if (count($parts) > 1) { + $pageSize = intval($parts[1]); + } + } + } + return $pageSize; + } + + public function getResultSize(array $params): int + { + $numberOfRows = -1; + if (isset($params['size'])) { + foreach ($params['size'] as $size) { + $numberOfRows = intval($size); + } + } + return $numberOfRows; + } + + public function getPageLimit(array $params): int + { + $pageLimit = -1; + if ($this->hasPage($params)) { + $pageLimit = $this->getPageSize($params); + } + $resultSize = $this->getResultSize($params); + if ($resultSize >= 0) { + if ($pageLimit >= 0) { + $pageLimit = min($pageLimit, $resultSize); + } else { + $pageLimit = $resultSize; + } + } + return $pageLimit; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/PathTree.php +namespace Tqdev\PhpCrudApi\Record { + + class PathTree implements \JsonSerializable + { + const WILDCARD = '*'; + + private $tree; + + public function __construct(/* object */&$tree = null) + { + if (!$tree) { + $tree = $this->newTree(); + } + $this->tree = &$tree; + } + + public function newTree() + { + return (object) ['values' => [], 'branches' => (object) []]; + } + + public function getKeys(): array + { + $branches = (array) $this->tree->branches; + return array_keys($branches); + } + + public function getValues(): array + { + return $this->tree->values; + } + + public function get(string $key): PathTree + { + if (!isset($this->tree->branches->$key)) { + return null; + } + return new PathTree($this->tree->branches->$key); + } + + public function put(array $path, $value) + { + $tree = &$this->tree; + foreach ($path as $key) { + if (!isset($tree->branches->$key)) { + $tree->branches->$key = $this->newTree(); + } + $tree = &$tree->branches->$key; + } + $tree->values[] = $value; + } + + public function match(array $path): array + { + $star = self::WILDCARD; + $tree = &$this->tree; + foreach ($path as $key) { + if (isset($tree->branches->$key)) { + $tree = &$tree->branches->$key; + } elseif (isset($tree->branches->$star)) { + $tree = &$tree->branches->$star; + } else { + return []; + } + } + return $tree->values; + } + + public static function fromJson(/* object */$tree): PathTree + { + return new PathTree($tree); + } + + public function jsonSerialize() + { + return $this->tree; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/RecordService.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\Record\Document\ListDocument; + + class RecordService + { + private $db; + private $reflection; + private $columns; + private $joiner; + private $filters; + private $ordering; + private $pagination; + + public function __construct(GenericDB $db, ReflectionService $reflection) + { + $this->db = $db; + $this->reflection = $reflection; + $this->columns = new ColumnIncluder(); + $this->joiner = new RelationJoiner($reflection, $this->columns); + $this->filters = new FilterInfo(); + $this->ordering = new OrderingInfo(); + $this->pagination = new PaginationInfo(); + } + + private function sanitizeRecord(string $tableName, /* object */ $record, string $id) + { + $keyset = array_keys((array) $record); + foreach ($keyset as $key) { + if (!$this->reflection->getTable($tableName)->hasColumn($key)) { + unset($record->$key); + } + } + if ($id != '') { + $pk = $this->reflection->getTable($tableName)->getPk(); + foreach ($this->reflection->getTable($tableName)->getColumnNames() as $key) { + $field = $this->reflection->getTable($tableName)->getColumn($key); + if ($field->getName() == $pk->getName()) { + unset($record->$key); + } + } + } + } + + public function hasTable(string $table): bool + { + return $this->reflection->hasTable($table); + } + + public function getType(string $table): string + { + return $this->reflection->getType($table); + } + + public function create(string $tableName, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, ''); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->createSingle($table, $columnValues); + } + + public function read(string $tableName, string $id, array $params) /*: ?object*/ + { + $table = $this->reflection->getTable($tableName); + $this->joiner->addMandatoryColumns($table, $params); + $columnNames = $this->columns->getNames($table, true, $params); + $record = $this->db->selectSingle($table, $columnNames, $id); + if ($record == null) { + return null; + } + $records = array($record); + $this->joiner->addJoins($table, $records, $params, $this->db); + return $records[0]; + } + + public function update(string $tableName, string $id, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, $id); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->updateSingle($table, $columnValues, $id); + } + + public function delete(string $tableName, string $id, array $params) /*: ?int*/ + { + $table = $this->reflection->getTable($tableName); + return $this->db->deleteSingle($table, $id); + } + + public function increment(string $tableName, string $id, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, $id); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->incrementSingle($table, $columnValues, $id); + } + + public function _list(string $tableName, array $params): ListDocument + { + $table = $this->reflection->getTable($tableName); + $this->joiner->addMandatoryColumns($table, $params); + $columnNames = $this->columns->getNames($table, true, $params); + $condition = $this->filters->getCombinedConditions($table, $params); + $columnOrdering = $this->ordering->getColumnOrdering($table, $params); + if (!$this->pagination->hasPage($params)) { + $offset = 0; + $limit = $this->pagination->getPageLimit($params); + $count = 0; + } else { + $offset = $this->pagination->getPageOffset($params); + $limit = $this->pagination->getPageLimit($params); + $count = $this->db->selectCount($table, $condition); + } + $records = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, $offset, $limit); + $this->joiner->addJoins($table, $records, $params, $this->db); + return new ListDocument($records, $count); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/RelationJoiner.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\Condition\OrCondition; + + class RelationJoiner + { + private $reflection; + private $ordering; + private $columns; + + public function __construct(ReflectionService $reflection, ColumnIncluder $columns) + { + $this->reflection = $reflection; + $this->ordering = new OrderingInfo(); + $this->columns = $columns; + } + + public function addMandatoryColumns(ReflectedTable $table, array &$params) /*: void*/ + { + if (!isset($params['join']) || !isset($params['include'])) { + return; + } + $params['mandatory'] = array(); + foreach ($params['join'] as $tableNames) { + $t1 = $table; + foreach (explode(',', $tableNames) as $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $t2 = $this->reflection->getTable($tableName); + $fks1 = $t1->getFksTo($t2->getName()); + $t3 = $this->hasAndBelongsToMany($t1, $t2); + if ($t3 != null || count($fks1) > 0) { + $params['mandatory'][] = $t2->getName() . '.' . $t2->getPk()->getName(); + } + foreach ($fks1 as $fk) { + $params['mandatory'][] = $t1->getName() . '.' . $fk->getName(); + } + $fks2 = $t2->getFksTo($t1->getName()); + if ($t3 != null || count($fks2) > 0) { + $params['mandatory'][] = $t1->getName() . '.' . $t1->getPk()->getName(); + } + foreach ($fks2 as $fk) { + $params['mandatory'][] = $t2->getName() . '.' . $fk->getName(); + } + $t1 = $t2; + } + } + } + + private function getJoinsAsPathTree(array $params): PathTree + { + $joins = new PathTree(); + if (isset($params['join'])) { + foreach ($params['join'] as $tableNames) { + $path = array(); + foreach (explode(',', $tableNames) as $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $t = $this->reflection->getTable($tableName); + if ($t != null) { + $path[] = $t->getName(); + } + } + $joins->put($path, true); + } + } + return $joins; + } + + public function addJoins(ReflectedTable $table, array &$records, array $params, GenericDB $db) /*: void*/ + { + $joins = $this->getJoinsAsPathTree($params); + $this->addJoinsForTables($table, $joins, $records, $params, $db); + } + + private function hasAndBelongsToMany(ReflectedTable $t1, ReflectedTable $t2) /*: ?ReflectedTable*/ + { + foreach ($this->reflection->getTableNames() as $tableName) { + $t3 = $this->reflection->getTable($tableName); + if (count($t3->getFksTo($t1->getName())) > 0 && count($t3->getFksTo($t2->getName())) > 0) { + return $t3; + } + } + return null; + } + + private function addJoinsForTables(ReflectedTable $t1, PathTree $joins, array &$records, array $params, GenericDB $db) + { + foreach ($joins->getKeys() as $t2Name) { + $t2 = $this->reflection->getTable($t2Name); + + $belongsTo = count($t1->getFksTo($t2->getName())) > 0; + $hasMany = count($t2->getFksTo($t1->getName())) > 0; + if (!$belongsTo && !$hasMany) { + $t3 = $this->hasAndBelongsToMany($t1, $t2); + } else { + $t3 = null; + } + $hasAndBelongsToMany = ($t3 != null); + + $newRecords = array(); + $fkValues = null; + $pkValues = null; + $habtmValues = null; + + if ($belongsTo) { + $fkValues = $this->getFkEmptyValues($t1, $t2, $records); + $this->addFkRecords($t2, $fkValues, $params, $db, $newRecords); + } + if ($hasMany) { + $pkValues = $this->getPkEmptyValues($t1, $records); + $this->addPkRecords($t1, $t2, $pkValues, $params, $db, $newRecords); + } + if ($hasAndBelongsToMany) { + $habtmValues = $this->getHabtmEmptyValues($t1, $t2, $t3, $db, $records); + $this->addFkRecords($t2, $habtmValues->fkValues, $params, $db, $newRecords); + } + + $this->addJoinsForTables($t2, $joins->get($t2Name), $newRecords, $params, $db); + + if ($fkValues != null) { + $this->fillFkValues($t2, $newRecords, $fkValues); + $this->setFkValues($t1, $t2, $records, $fkValues); + } + if ($pkValues != null) { + $this->fillPkValues($t1, $t2, $newRecords, $pkValues); + $this->setPkValues($t1, $t2, $records, $pkValues); + } + if ($habtmValues != null) { + $this->fillFkValues($t2, $newRecords, $habtmValues->fkValues); + $this->setHabtmValues($t1, $t2, $records, $habtmValues); + } + } + } + + private function getFkEmptyValues(ReflectedTable $t1, ReflectedTable $t2, array $records): array + { + $fkValues = array(); + $fks = $t1->getFksTo($t2->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($records as $record) { + if (isset($record[$fkName])) { + $fkValue = $record[$fkName]; + $fkValues[$fkValue] = null; + } + } + } + return $fkValues; + } + + private function addFkRecords(ReflectedTable $t2, array $fkValues, array $params, GenericDB $db, array &$records) /*: void*/ + { + $columnNames = $this->columns->getNames($t2, false, $params); + $fkIds = array_keys($fkValues); + + foreach ($db->selectMultiple($t2, $columnNames, $fkIds) as $record) { + $records[] = $record; + } + } + + private function fillFkValues(ReflectedTable $t2, array $fkRecords, array &$fkValues) /*: void*/ + { + $pkName = $t2->getPk()->getName(); + foreach ($fkRecords as $fkRecord) { + $pkValue = $fkRecord[$pkName]; + $fkValues[$pkValue] = $fkRecord; + } + } + + private function setFkValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, array $fkValues) /*: void*/ + { + $fks = $t1->getFksTo($t2->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($records as $i => $record) { + if (isset($record[$fkName])) { + $key = $record[$fkName]; + $records[$i][$fkName] = $fkValues[$key]; + } + } + } + } + + private function getPkEmptyValues(ReflectedTable $t1, array $records): array + { + $pkValues = array(); + $pkName = $t1->getPk()->getName(); + foreach ($records as $record) { + $key = $record[$pkName]; + $pkValues[$key] = array(); + } + return $pkValues; + } + + private function addPkRecords(ReflectedTable $t1, ReflectedTable $t2, array $pkValues, array $params, GenericDB $db, array &$records) /*: void*/ + { + $fks = $t2->getFksTo($t1->getName()); + $columnNames = $this->columns->getNames($t2, false, $params); + $pkValueKeys = implode(',', array_keys($pkValues)); + $conditions = array(); + foreach ($fks as $fk) { + $conditions[] = new ColumnCondition($fk, 'in', $pkValueKeys); + } + $condition = OrCondition::fromArray($conditions); + $columnOrdering = array(); + $limit = VariableStore::get("joinLimits.maxRecords") ?: -1; + if ($limit != -1) { + $columnOrdering = $this->ordering->getDefaultColumnOrdering($t2); + } + foreach ($db->selectAll($t2, $columnNames, $condition, $columnOrdering, 0, $limit) as $record) { + $records[] = $record; + } + } + + private function fillPkValues(ReflectedTable $t1, ReflectedTable $t2, array $pkRecords, array &$pkValues) /*: void*/ + { + $fks = $t2->getFksTo($t1->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($pkRecords as $pkRecord) { + $key = $pkRecord[$fkName]; + if (isset($pkValues[$key])) { + $pkValues[$key][] = $pkRecord; + } + } + } + } + + private function setPkValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, array $pkValues) /*: void*/ + { + $pkName = $t1->getPk()->getName(); + $t2Name = $t2->getName(); + + foreach ($records as $i => $record) { + $key = $record[$pkName]; + $records[$i][$t2Name] = $pkValues[$key]; + } + } + + private function getHabtmEmptyValues(ReflectedTable $t1, ReflectedTable $t2, ReflectedTable $t3, GenericDB $db, array $records): HabtmValues + { + $pkValues = $this->getPkEmptyValues($t1, $records); + $fkValues = array(); + + $fk1 = $t3->getFksTo($t1->getName())[0]; + $fk2 = $t3->getFksTo($t2->getName())[0]; + + $fk1Name = $fk1->getName(); + $fk2Name = $fk2->getName(); + + $columnNames = array($fk1Name, $fk2Name); + + $pkIds = implode(',', array_keys($pkValues)); + $condition = new ColumnCondition($t3->getColumn($fk1Name), 'in', $pkIds); + $columnOrdering = array(); + + $limit = VariableStore::get("joinLimits.maxRecords") ?: -1; + if ($limit != -1) { + $columnOrdering = $this->ordering->getDefaultColumnOrdering($t3); + } + $records = $db->selectAll($t3, $columnNames, $condition, $columnOrdering, 0, $limit); + foreach ($records as $record) { + $val1 = $record[$fk1Name]; + $val2 = $record[$fk2Name]; + $pkValues[$val1][] = $val2; + $fkValues[$val2] = null; + } + + return new HabtmValues($pkValues, $fkValues); + } + + private function setHabtmValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, HabtmValues $habtmValues) /*: void*/ + { + $pkName = $t1->getPk()->getName(); + $t2Name = $t2->getName(); + foreach ($records as $i => $record) { + $key = $record[$pkName]; + $val = array(); + $fks = $habtmValues->pkValues[$key]; + foreach ($fks as $fk) { + $val[] = $habtmValues->fkValues[$fk]; + } + $records[$i][$t2Name] = $val; + } + } + } +} + +// file: src/Tqdev/PhpCrudApi/Api.php +namespace Tqdev\PhpCrudApi { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Cache\CacheFactory; + use Tqdev\PhpCrudApi\Column\DefinitionService; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\CacheController; + use Tqdev\PhpCrudApi\Controller\ColumnController; + use Tqdev\PhpCrudApi\Controller\GeoJsonController; + use Tqdev\PhpCrudApi\Controller\JsonResponder; + use Tqdev\PhpCrudApi\Controller\OpenApiController; + use Tqdev\PhpCrudApi\Controller\RecordController; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\GeoJson\GeoJsonService; + use Tqdev\PhpCrudApi\Middleware\AuthorizationMiddleware; + use Tqdev\PhpCrudApi\Middleware\BasicAuthMiddleware; + use Tqdev\PhpCrudApi\Middleware\CorsMiddleware; + use Tqdev\PhpCrudApi\Middleware\CustomizationMiddleware; + use Tqdev\PhpCrudApi\Middleware\DbAuthMiddleware; + use Tqdev\PhpCrudApi\Middleware\FirewallMiddleware; + use Tqdev\PhpCrudApi\Middleware\IpAddressMiddleware; + use Tqdev\PhpCrudApi\Middleware\JoinLimitsMiddleware; + use Tqdev\PhpCrudApi\Middleware\JwtAuthMiddleware; + use Tqdev\PhpCrudApi\Middleware\MultiTenancyMiddleware; + use Tqdev\PhpCrudApi\Middleware\PageLimitsMiddleware; + use Tqdev\PhpCrudApi\Middleware\ReconnectMiddleware; + use Tqdev\PhpCrudApi\Middleware\Router\SimpleRouter; + use Tqdev\PhpCrudApi\Middleware\SanitationMiddleware; + use Tqdev\PhpCrudApi\Middleware\SslRedirectMiddleware; + use Tqdev\PhpCrudApi\Middleware\ValidationMiddleware; + use Tqdev\PhpCrudApi\Middleware\XmlMiddleware; + use Tqdev\PhpCrudApi\Middleware\XsrfMiddleware; + use Tqdev\PhpCrudApi\OpenApi\OpenApiService; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\RecordService; + use Tqdev\PhpCrudApi\ResponseUtils; + + class Api implements RequestHandlerInterface + { + private $router; + private $responder; + private $debug; + + public function __construct(Config $config) + { + $db = new GenericDB( + $config->getDriver(), + $config->getAddress(), + $config->getPort(), + $config->getDatabase(), + $config->getTables(), + $config->getUsername(), + $config->getPassword() + ); + $prefix = sprintf('phpcrudapi-%s-', substr(md5(__FILE__), 0, 8)); + $cache = CacheFactory::create($config->getCacheType(), $prefix, $config->getCachePath()); + $reflection = new ReflectionService($db, $cache, $config->getCacheTime()); + $responder = new JsonResponder(); + $router = new SimpleRouter($config->getBasePath(), $responder, $cache, $config->getCacheTime(), $config->getDebug()); + foreach ($config->getMiddlewares() as $middleware => $properties) { + switch ($middleware) { + case 'sslRedirect': + new SslRedirectMiddleware($router, $responder, $properties); + break; + case 'cors': + new CorsMiddleware($router, $responder, $properties, $config->getDebug()); + break; + case 'firewall': + new FirewallMiddleware($router, $responder, $properties); + break; + case 'basicAuth': + new BasicAuthMiddleware($router, $responder, $properties); + break; + case 'jwtAuth': + new JwtAuthMiddleware($router, $responder, $properties); + break; + case 'dbAuth': + new DbAuthMiddleware($router, $responder, $properties, $reflection, $db); + break; + case 'reconnect': + new ReconnectMiddleware($router, $responder, $properties, $reflection, $db); + break; + case 'validation': + new ValidationMiddleware($router, $responder, $properties, $reflection); + break; + case 'ipAddress': + new IpAddressMiddleware($router, $responder, $properties, $reflection); + break; + case 'sanitation': + new SanitationMiddleware($router, $responder, $properties, $reflection); + break; + case 'multiTenancy': + new MultiTenancyMiddleware($router, $responder, $properties, $reflection); + break; + case 'authorization': + new AuthorizationMiddleware($router, $responder, $properties, $reflection); + break; + case 'xsrf': + new XsrfMiddleware($router, $responder, $properties); + break; + case 'pageLimits': + new PageLimitsMiddleware($router, $responder, $properties, $reflection); + break; + case 'joinLimits': + new JoinLimitsMiddleware($router, $responder, $properties, $reflection); + break; + case 'customization': + new CustomizationMiddleware($router, $responder, $properties, $reflection); + break; + case 'xml': + new XmlMiddleware($router, $responder, $properties, $reflection); + break; + } + } + foreach ($config->getControllers() as $controller) { + switch ($controller) { + case 'records': + $records = new RecordService($db, $reflection); + new RecordController($router, $responder, $records); + break; + case 'columns': + $definition = new DefinitionService($db, $reflection); + new ColumnController($router, $responder, $reflection, $definition); + break; + case 'cache': + new CacheController($router, $responder, $cache); + break; + case 'openapi': + $openApi = new OpenApiService($reflection, $config->getOpenApiBase(), $config->getControllers(), $config->getCustomOpenApiBuilders()); + new OpenApiController($router, $responder, $openApi); + break; + case 'geojson': + $records = new RecordService($db, $reflection); + $geoJson = new GeoJsonService($reflection, $records); + new GeoJsonController($router, $responder, $geoJson); + break; + } + } + foreach ($config->getCustomControllers() as $className) { + if (class_exists($className)) { + $records = new RecordService($db, $reflection); + new $className($router, $responder, $records); + } + } + $this->router = $router; + $this->responder = $responder; + $this->debug = $config->getDebug(); + } + + private function parseBody(string $body) /*: ?object*/ + { + $first = substr($body, 0, 1); + if ($first == '[' || $first == '{') { + $object = json_decode($body); + $causeCode = json_last_error(); + if ($causeCode !== JSON_ERROR_NONE) { + $object = null; + } + } else { + parse_str($body, $input); + foreach ($input as $key => $value) { + if (substr($key, -9) == '__is_null') { + $input[substr($key, 0, -9)] = null; + unset($input[$key]); + } + } + $object = (object) $input; + } + return $object; + } + + private function addParsedBody(ServerRequestInterface $request): ServerRequestInterface + { + $parsedBody = $request->getParsedBody(); + if ($parsedBody) { + $request = $this->applyParsedBodyHack($request); + } else { + $body = $request->getBody(); + if ($body->isReadable()) { + if ($body->isSeekable()) { + $body->rewind(); + } + $contents = $body->getContents(); + if ($body->isSeekable()) { + $body->rewind(); + } + if ($contents) { + $parsedBody = $this->parseBody($contents); + $request = $request->withParsedBody($parsedBody); + } + } + } + return $request; + } + + private function applyParsedBodyHack(ServerRequestInterface $request): ServerRequestInterface + { + $parsedBody = $request->getParsedBody(); + if (is_array($parsedBody)) { // is it really? + $contents = json_encode($parsedBody); + $parsedBody = $this->parseBody($contents); + $request = $request->withParsedBody($parsedBody); + } + return $request; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->router->route($this->addParsedBody($request)); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Config.php +namespace Tqdev\PhpCrudApi { + + class Config + { + private $values = [ + 'driver' => null, + 'address' => 'localhost', + 'port' => null, + 'username' => null, + 'password' => null, + 'database' => null, + 'tables' => '', + 'middlewares' => 'cors,errors', + 'controllers' => 'records,geojson,openapi', + 'customControllers' => '', + 'customOpenApiBuilders' => '', + 'cacheType' => 'TempFile', + 'cachePath' => '', + 'cacheTime' => 10, + 'debug' => false, + 'basePath' => '', + 'openApiBase' => '{"info":{"title":"PHP-CRUD-API","version":"1.0.0"}}', + ]; + + private function getDefaultDriver(array $values): string + { + if (isset($values['driver'])) { + return $values['driver']; + } + return 'mysql'; + } + + private function getDefaultPort(string $driver): int + { + switch ($driver) { + case 'mysql': + return 3306; + case 'pgsql': + return 5432; + case 'sqlsrv': + return 1433; + case 'sqlite': + return 0; + } + } + + private function getDefaultAddress(string $driver): string + { + switch ($driver) { + case 'mysql': + return 'localhost'; + case 'pgsql': + return 'localhost'; + case 'sqlsrv': + return 'localhost'; + case 'sqlite': + return 'data.db'; + } + } + + private function getDriverDefaults(string $driver): array + { + return [ + 'driver' => $driver, + 'address' => $this->getDefaultAddress($driver), + 'port' => $this->getDefaultPort($driver), + ]; + } + + private function applyEnvironmentVariables(array $values): array + { + $newValues = array(); + foreach ($values as $key => $value) { + $environmentKey = 'PHP_CRUD_API_' . strtoupper(preg_replace('/(?getDefaultDriver($values); + $defaults = $this->getDriverDefaults($driver); + $newValues = array_merge($this->values, $defaults, $values); + $newValues = $this->parseMiddlewares($newValues); + $diff = array_diff_key($newValues, $this->values); + if (!empty($diff)) { + $key = array_keys($diff)[0]; + throw new \Exception("Config has invalid value '$key'"); + } + $newValues = $this->applyEnvironmentVariables($newValues); + $this->values = $newValues; + } + + private function parseMiddlewares(array $values): array + { + $newValues = array(); + $properties = array(); + $middlewares = array_map('trim', explode(',', $values['middlewares'])); + foreach ($middlewares as $middleware) { + $properties[$middleware] = []; + } + foreach ($values as $key => $value) { + if (strpos($key, '.') === false) { + $newValues[$key] = $value; + } else { + list($middleware, $key2) = explode('.', $key, 2); + if (isset($properties[$middleware])) { + $properties[$middleware][$key2] = $value; + } else { + throw new \Exception("Config has invalid value '$key'"); + } + } + } + $newValues['middlewares'] = array_reverse($properties, true); + return $newValues; + } + + public function getDriver(): string + { + return $this->values['driver']; + } + + public function getAddress(): string + { + return $this->values['address']; + } + + public function getPort(): int + { + return $this->values['port']; + } + + public function getUsername(): string + { + return $this->values['username']; + } + + public function getPassword(): string + { + return $this->values['password']; + } + + public function getDatabase(): string + { + return $this->values['database']; + } + + public function getTables(): array + { + return array_filter(array_map('trim', explode(',', $this->values['tables']))); + } + + public function getMiddlewares(): array + { + return $this->values['middlewares']; + } + + public function getControllers(): array + { + return array_filter(array_map('trim', explode(',', $this->values['controllers']))); + } + + public function getCustomControllers(): array + { + return array_filter(array_map('trim', explode(',', $this->values['customControllers']))); + } + + public function getCustomOpenApiBuilders(): array + { + return array_filter(array_map('trim', explode(',', $this->values['customOpenApiBuilders']))); + } + + public function getCacheType(): string + { + return $this->values['cacheType']; + } + + public function getCachePath(): string + { + return $this->values['cachePath']; + } + + public function getCacheTime(): int + { + return $this->values['cacheTime']; + } + + public function getDebug(): bool + { + return $this->values['debug']; + } + + public function getBasePath(): string + { + return $this->values['basePath']; + } + + public function getOpenApiBase(): array + { + return json_decode($this->values['openApiBase'], true); + } + } +} + +// file: src/Tqdev/PhpCrudApi/RequestFactory.php +namespace Tqdev\PhpCrudApi { + + use Nyholm\Psr7\Factory\Psr17Factory; + use Nyholm\Psr7Server\ServerRequestCreator; + use Psr\Http\Message\ServerRequestInterface; + + class RequestFactory + { + public static function fromGlobals(): ServerRequestInterface + { + $psr17Factory = new Psr17Factory(); + $creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + $serverRequest = $creator->fromGlobals(); + $stream = $psr17Factory->createStreamFromFile('php://input'); + $serverRequest = $serverRequest->withBody($stream); + return $serverRequest; + } + + public static function fromString(string $request): ServerRequestInterface + { + $parts = explode("\n\n", trim($request), 2); + $lines = explode("\n", $parts[0]); + $first = explode(' ', trim(array_shift($lines)), 2); + $method = $first[0]; + $body = isset($parts[1]) ? $parts[1] : ''; + $url = isset($first[1]) ? $first[1] : ''; + + $psr17Factory = new Psr17Factory(); + $serverRequest = $psr17Factory->createServerRequest($method, $url); + foreach ($lines as $line) { + list($key, $value) = explode(':', $line, 2); + $serverRequest = $serverRequest->withAddedHeader($key, $value); + } + if ($body) { + $stream = $psr17Factory->createStream($body); + $stream->rewind(); + $serverRequest = $serverRequest->withBody($stream); + } + return $serverRequest; + } + } +} + +// file: src/Tqdev/PhpCrudApi/RequestUtils.php +namespace Tqdev\PhpCrudApi { + + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + + class RequestUtils + { + public static function setParams(ServerRequestInterface $request, array $params): ServerRequestInterface + { + $query = preg_replace('|%5B[0-9]+%5D=|', '=', http_build_query($params)); + return $request->withUri($request->getUri()->withQuery($query)); + } + + public static function getHeader(ServerRequestInterface $request, string $header): string + { + $headers = $request->getHeader($header); + return isset($headers[0]) ? $headers[0] : ''; + } + + public static function getParams(ServerRequestInterface $request): array + { + $params = array(); + $query = $request->getUri()->getQuery(); + //$query = str_replace('][]=', ']=', str_replace('=', '[]=', $query)); + $query = str_replace('%5D%5B%5D=', '%5D=', str_replace('=', '%5B%5D=', $query)); + parse_str($query, $params); + return $params; + } + + public static function getPathSegment(ServerRequestInterface $request, int $part): string + { + $path = $request->getUri()->getPath(); + $pathSegments = explode('/', rtrim($path, '/')); + if ($part < 0 || $part >= count($pathSegments)) { + return ''; + } + return urldecode($pathSegments[$part]); + } + + public static function getOperation(ServerRequestInterface $request): string + { + $method = $request->getMethod(); + $path = RequestUtils::getPathSegment($request, 1); + $hasPk = RequestUtils::getPathSegment($request, 3) != ''; + switch ($path) { + case 'openapi': + return 'document'; + case 'columns': + return $method == 'get' ? 'reflect' : 'remodel'; + case 'geojson': + case 'records': + switch ($method) { + case 'POST': + return 'create'; + case 'GET': + return $hasPk ? 'read' : 'list'; + case 'PUT': + return 'update'; + case 'DELETE': + return 'delete'; + case 'PATCH': + return 'increment'; + } + } + return 'unknown'; + } + + private static function getJoinTables(string $tableName, array $parameters): array + { + $uniqueTableNames = array(); + $uniqueTableNames[$tableName] = true; + if (isset($parameters['join'])) { + foreach ($parameters['join'] as $parameter) { + $tableNames = explode(',', trim($parameter)); + foreach ($tableNames as $tableName) { + $uniqueTableNames[$tableName] = true; + } + } + } + return array_keys($uniqueTableNames); + } + + public static function getTableNames(ServerRequestInterface $request, ReflectionService $reflection): array + { + $path = RequestUtils::getPathSegment($request, 1); + $tableName = RequestUtils::getPathSegment($request, 2); + $allTableNames = $reflection->getTableNames(); + switch ($path) { + case 'openapi': + return $allTableNames; + case 'columns': + return $tableName ? [$tableName] : $allTableNames; + case 'records': + return self::getJoinTables($tableName, RequestUtils::getParams($request)); + } + return $allTableNames; + } + } +} + +// file: src/Tqdev/PhpCrudApi/ResponseFactory.php +namespace Tqdev\PhpCrudApi { + + use Nyholm\Psr7\Factory\Psr17Factory; + use Psr\Http\Message\ResponseInterface; + + class ResponseFactory + { + const OK = 200; + const MOVED_PERMANENTLY = 301; + const FOUND = 302; + const UNAUTHORIZED = 401; + const FORBIDDEN = 403; + const NOT_FOUND = 404; + const METHOD_NOT_ALLOWED = 405; + const CONFLICT = 409; + const UNPROCESSABLE_ENTITY = 422; + const INTERNAL_SERVER_ERROR = 500; + + public static function fromXml(int $status, string $xml): ResponseInterface + { + return self::from($status, 'text/xml', $xml); + } + + public static function fromCsv(int $status, string $csv): ResponseInterface + { + return self::from($status, 'text/csv', $csv); + } + + public static function fromHtml(int $status, string $html): ResponseInterface + { + return self::from($status, 'text/html', $html); + } + + public static function fromObject(int $status, $body): ResponseInterface + { + $content = json_encode($body, JSON_UNESCAPED_UNICODE); + return self::from($status, 'application/json', $content); + } + + public static function from(int $status, string $contentType, string $content): ResponseInterface + { + $psr17Factory = new Psr17Factory(); + $response = $psr17Factory->createResponse($status); + $stream = $psr17Factory->createStream($content); + $stream->rewind(); + $response = $response->withBody($stream); + $response = $response->withHeader('Content-Type', $contentType . '; charset=utf-8'); + $response = $response->withHeader('Content-Length', strlen($content)); + return $response; + } + + public static function fromStatus(int $status): ResponseInterface + { + $psr17Factory = new Psr17Factory(); + return $psr17Factory->createResponse($status); + } + } +} + +// file: src/Tqdev/PhpCrudApi/ResponseUtils.php +namespace Tqdev\PhpCrudApi { + + use Psr\Http\Message\ResponseInterface; + + class ResponseUtils + { + public static function output(ResponseInterface $response) + { + $status = $response->getStatusCode(); + $headers = $response->getHeaders(); + $body = $response->getBody()->getContents(); + + http_response_code($status); + foreach ($headers as $key => $values) { + foreach ($values as $value) { + header("$key: $value"); + } + } + echo $body; + } + + public static function addExceptionHeaders(ResponseInterface $response, \Throwable $e): ResponseInterface + { + $response = $response->withHeader('X-Exception-Name', get_class($e)); + $response = $response->withHeader('X-Exception-Message', preg_replace('|\n|', ' ', trim($e->getMessage()))); + $response = $response->withHeader('X-Exception-File', $e->getFile() . ':' . $e->getLine()); + return $response; + } + + public static function toString(ResponseInterface $response): string + { + $status = $response->getStatusCode(); + $headers = $response->getHeaders(); + $body = $response->getBody()->getContents(); + + $str = "$status\n"; + foreach ($headers as $key => $values) { + foreach ($values as $value) { + $str .= "$key: $value\n"; + } + } + if ($body !== '') { + $str .= "\n"; + $str .= "$body\n"; + } + return $str; + } + } +} diff --git a/api.php b/api.php new file mode 100644 index 0000000..9a7512f --- /dev/null +++ b/api.php @@ -0,0 +1,11410 @@ +getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return string[][] Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders(); + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name Case-insensitive header field name. + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader($name); + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($name); + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name Case-insensitive header field name. + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine($name); + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader($name, $value); + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader($name, $value); + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader($name); + + /** + * Gets the body of the message. + * + * @return StreamInterface Returns the body as a stream. + */ + public function getBody(); + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody(StreamInterface $body); + } +} + +// file: vendor/psr/http-message/src/RequestInterface.php +namespace Psr\Http\Message { + + /** + * Representation of an outgoing, client-side request. + * + * Per the HTTP specification, this interface includes properties for + * each of the following: + * + * - Protocol version + * - HTTP method + * - URI + * - Headers + * - Message body + * + * During construction, implementations MUST attempt to set the Host header from + * a provided URI if no Host header is provided. + * + * Requests are considered immutable; all methods that might change state MUST + * be implemented such that they retain the internal state of the current + * message and return an instance that contains the changed state. + */ + interface RequestInterface extends MessageInterface + { + /** + * Retrieves the message's request target. + * + * Retrieves the message's request-target either as it will appear (for + * clients), as it appeared at request (for servers), or as it was + * specified for the instance (see withRequestTarget()). + * + * In most cases, this will be the origin-form of the composed URI, + * unless a value was provided to the concrete implementation (see + * withRequestTarget() below). + * + * If no URI is available, and no request-target has been specifically + * provided, this method MUST return the string "/". + * + * @return string + */ + public function getRequestTarget(); + + /** + * Return an instance with the specific request-target. + * + * If the request needs a non-origin-form request-target — e.g., for + * specifying an absolute-form, authority-form, or asterisk-form — + * this method may be used to create an instance with the specified + * request-target, verbatim. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request target. + * + * @link http://tools.ietf.org/html/rfc7230#section-5.3 (for the various + * request-target forms allowed in request messages) + * @param mixed $requestTarget + * @return static + */ + public function withRequestTarget($requestTarget); + + /** + * Retrieves the HTTP method of the request. + * + * @return string Returns the request method. + */ + public function getMethod(); + + /** + * Return an instance with the provided HTTP method. + * + * While HTTP method names are typically all uppercase characters, HTTP + * method names are case-sensitive and thus implementations SHOULD NOT + * modify the given string. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request method. + * + * @param string $method Case-sensitive method. + * @return static + * @throws \InvalidArgumentException for invalid HTTP methods. + */ + public function withMethod($method); + + /** + * Retrieves the URI instance. + * + * This method MUST return a UriInterface instance. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @return UriInterface Returns a UriInterface instance + * representing the URI of the request. + */ + public function getUri(); + + /** + * Returns an instance with the provided URI. + * + * This method MUST update the Host header of the returned request by + * default if the URI contains a host component. If the URI does not + * contain a host component, any pre-existing Host header MUST be carried + * over to the returned request. + * + * You can opt-in to preserving the original state of the Host header by + * setting `$preserveHost` to `true`. When `$preserveHost` is set to + * `true`, this method interacts with the Host header in the following ways: + * + * - If the Host header is missing or empty, and the new URI contains + * a host component, this method MUST update the Host header in the returned + * request. + * - If the Host header is missing or empty, and the new URI does not contain a + * host component, this method MUST NOT update the Host header in the returned + * request. + * - If a Host header is present and non-empty, this method MUST NOT update + * the Host header in the returned request. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new UriInterface instance. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @param UriInterface $uri New request URI to use. + * @param bool $preserveHost Preserve the original state of the Host header. + * @return static + */ + public function withUri(UriInterface $uri, $preserveHost = false); + } +} + +// file: vendor/psr/http-message/src/ResponseInterface.php +namespace Psr\Http\Message { + + /** + * Representation of an outgoing, server-side response. + * + * Per the HTTP specification, this interface includes properties for + * each of the following: + * + * - Protocol version + * - Status code and reason phrase + * - Headers + * - Message body + * + * Responses are considered immutable; all methods that might change state MUST + * be implemented such that they retain the internal state of the current + * message and return an instance that contains the changed state. + */ + interface ResponseInterface extends MessageInterface + { + /** + * Gets the response status code. + * + * The status code is a 3-digit integer result code of the server's attempt + * to understand and satisfy the request. + * + * @return int Status code. + */ + public function getStatusCode(); + + /** + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification. + * @return static + * @throws \InvalidArgumentException For invalid status code arguments. + */ + public function withStatus($code, $reasonPhrase = ''); + + /** + * Gets the response reason phrase associated with the status code. + * + * Because a reason phrase is not a required element in a response + * status line, the reason phrase value MAY be null. Implementations MAY + * choose to return the default RFC 7231 recommended reason phrase (or those + * listed in the IANA HTTP Status Code Registry) for the response's + * status code. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @return string Reason phrase; must return an empty string if none present. + */ + public function getReasonPhrase(); + } +} + +// file: vendor/psr/http-message/src/ServerRequestInterface.php +namespace Psr\Http\Message { + + /** + * Representation of an incoming, server-side HTTP request. + * + * Per the HTTP specification, this interface includes properties for + * each of the following: + * + * - Protocol version + * - HTTP method + * - URI + * - Headers + * - Message body + * + * Additionally, it encapsulates all data as it has arrived to the + * application from the CGI and/or PHP environment, including: + * + * - The values represented in $_SERVER. + * - Any cookies provided (generally via $_COOKIE) + * - Query string arguments (generally via $_GET, or as parsed via parse_str()) + * - Upload files, if any (as represented by $_FILES) + * - Deserialized body parameters (generally from $_POST) + * + * $_SERVER values MUST be treated as immutable, as they represent application + * state at the time of request; as such, no methods are provided to allow + * modification of those values. The other values provide such methods, as they + * can be restored from $_SERVER or the request body, and may need treatment + * during the application (e.g., body parameters may be deserialized based on + * content type). + * + * Additionally, this interface recognizes the utility of introspecting a + * request to derive and match additional parameters (e.g., via URI path + * matching, decrypting cookie values, deserializing non-form-encoded body + * content, matching authorization headers to users, etc). These parameters + * are stored in an "attributes" property. + * + * Requests are considered immutable; all methods that might change state MUST + * be implemented such that they retain the internal state of the current + * message and return an instance that contains the changed state. + */ + interface ServerRequestInterface extends RequestInterface + { + /** + * Retrieve server parameters. + * + * Retrieves data related to the incoming request environment, + * typically derived from PHP's $_SERVER superglobal. The data IS NOT + * REQUIRED to originate from $_SERVER. + * + * @return array + */ + public function getServerParams(); + + /** + * Retrieve cookies. + * + * Retrieves cookies sent by the client to the server. + * + * The data MUST be compatible with the structure of the $_COOKIE + * superglobal. + * + * @return array + */ + public function getCookieParams(); + + /** + * Return an instance with the specified cookies. + * + * The data IS NOT REQUIRED to come from the $_COOKIE superglobal, but MUST + * be compatible with the structure of $_COOKIE. Typically, this data will + * be injected at instantiation. + * + * This method MUST NOT update the related Cookie header of the request + * instance, nor related values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated cookie values. + * + * @param array $cookies Array of key/value pairs representing cookies. + * @return static + */ + public function withCookieParams(array $cookies); + + /** + * Retrieve query string arguments. + * + * Retrieves the deserialized query string arguments, if any. + * + * Note: the query params might not be in sync with the URI or server + * params. If you need to ensure you are only getting the original + * values, you may need to parse the query string from `getUri()->getQuery()` + * or from the `QUERY_STRING` server param. + * + * @return array + */ + public function getQueryParams(); + + /** + * Return an instance with the specified query string arguments. + * + * These values SHOULD remain immutable over the course of the incoming + * request. They MAY be injected during instantiation, such as from PHP's + * $_GET superglobal, or MAY be derived from some other value such as the + * URI. In cases where the arguments are parsed from the URI, the data + * MUST be compatible with what PHP's parse_str() would return for + * purposes of how duplicate query parameters are handled, and how nested + * sets are handled. + * + * Setting query string arguments MUST NOT change the URI stored by the + * request, nor the values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated query string arguments. + * + * @param array $query Array of query string arguments, typically from + * $_GET. + * @return static + */ + public function withQueryParams(array $query); + + /** + * Retrieve normalized file upload data. + * + * This method returns upload metadata in a normalized tree, with each leaf + * an instance of Psr\Http\Message\UploadedFileInterface. + * + * These values MAY be prepared from $_FILES or the message body during + * instantiation, or MAY be injected via withUploadedFiles(). + * + * @return array An array tree of UploadedFileInterface instances; an empty + * array MUST be returned if no data is present. + */ + public function getUploadedFiles(); + + /** + * Create a new instance with the specified uploaded files. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param array $uploadedFiles An array tree of UploadedFileInterface instances. + * @return static + * @throws \InvalidArgumentException if an invalid structure is provided. + */ + public function withUploadedFiles(array $uploadedFiles); + + /** + * Retrieve any parameters provided in the request body. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, this method MUST + * return the contents of $_POST. + * + * Otherwise, this method may return any results of deserializing + * the request body content; as parsing returns structured content, the + * potential types MUST be arrays or objects only. A null value indicates + * the absence of body content. + * + * @return null|array|object The deserialized body parameters, if any. + * These will typically be an array or object. + */ + public function getParsedBody(); + + /** + * Return an instance with the specified body parameters. + * + * These MAY be injected during instantiation. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, use this method + * ONLY to inject the contents of $_POST. + * + * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of + * deserializing the request body content. Deserialization/parsing returns + * structured data, and, as such, this method ONLY accepts arrays or objects, + * or a null value if nothing was available to parse. + * + * As an example, if content negotiation determines that the request data + * is a JSON payload, this method could be used to create a request + * instance with the deserialized parameters. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param null|array|object $data The deserialized body data. This will + * typically be in an array or object. + * @return static + * @throws \InvalidArgumentException if an unsupported argument type is + * provided. + */ + public function withParsedBody($data); + + /** + * Retrieve attributes derived from the request. + * + * The request "attributes" may be used to allow injection of any + * parameters derived from the request: e.g., the results of path + * match operations; the results of decrypting cookies; the results of + * deserializing non-form-encoded message bodies; etc. Attributes + * will be application and request specific, and CAN be mutable. + * + * @return array Attributes derived from the request. + */ + public function getAttributes(); + + /** + * Retrieve a single derived request attribute. + * + * Retrieves a single derived request attribute as described in + * getAttributes(). If the attribute has not been previously set, returns + * the default value as provided. + * + * This method obviates the need for a hasAttribute() method, as it allows + * specifying a default value to return if the attribute is not found. + * + * @see getAttributes() + * @param string $name The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + * @return mixed + */ + public function getAttribute($name, $default = null); + + /** + * Return an instance with the specified derived request attribute. + * + * This method allows setting a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated attribute. + * + * @see getAttributes() + * @param string $name The attribute name. + * @param mixed $value The value of the attribute. + * @return static + */ + public function withAttribute($name, $value); + + /** + * Return an instance that removes the specified derived request attribute. + * + * This method allows removing a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the attribute. + * + * @see getAttributes() + * @param string $name The attribute name. + * @return static + */ + public function withoutAttribute($name); + } +} + +// file: vendor/psr/http-message/src/StreamInterface.php +namespace Psr\Http\Message { + + /** + * Describes a data stream. + * + * Typically, an instance will wrap a PHP stream; this interface provides + * a wrapper around the most common operations, including serialization of + * the entire stream to a string. + */ + interface StreamInterface + { + /** + * Reads all data from the stream into a string, from the beginning to end. + * + * This method MUST attempt to seek to the beginning of the stream before + * reading data and read the stream until the end is reached. + * + * Warning: This could attempt to load a large amount of data into memory. + * + * This method MUST NOT raise an exception in order to conform with PHP's + * string casting operations. + * + * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring + * @return string + */ + public function __toString(); + + /** + * Closes the stream and any underlying resources. + * + * @return void + */ + public function close(); + + /** + * Separates any underlying resources from the stream. + * + * After the stream has been detached, the stream is in an unusable state. + * + * @return resource|null Underlying PHP stream, if any + */ + public function detach(); + + /** + * Get the size of the stream if known. + * + * @return int|null Returns the size in bytes if known, or null if unknown. + */ + public function getSize(); + + /** + * Returns the current position of the file read/write pointer + * + * @return int Position of the file pointer + * @throws \RuntimeException on error. + */ + public function tell(); + + /** + * Returns true if the stream is at the end of the stream. + * + * @return bool + */ + public function eof(); + + /** + * Returns whether or not the stream is seekable. + * + * @return bool + */ + public function isSeekable(); + + /** + * Seek to a position in the stream. + * + * @link http://www.php.net/manual/en/function.fseek.php + * @param int $offset Stream offset + * @param int $whence Specifies how the cursor position will be calculated + * based on the seek offset. Valid values are identical to the built-in + * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to + * offset bytes SEEK_CUR: Set position to current location plus offset + * SEEK_END: Set position to end-of-stream plus offset. + * @throws \RuntimeException on failure. + */ + public function seek($offset, $whence = SEEK_SET); + + /** + * Seek to the beginning of the stream. + * + * If the stream is not seekable, this method will raise an exception; + * otherwise, it will perform a seek(0). + * + * @see seek() + * @link http://www.php.net/manual/en/function.fseek.php + * @throws \RuntimeException on failure. + */ + public function rewind(); + + /** + * Returns whether or not the stream is writable. + * + * @return bool + */ + public function isWritable(); + + /** + * Write data to the stream. + * + * @param string $string The string that is to be written. + * @return int Returns the number of bytes written to the stream. + * @throws \RuntimeException on failure. + */ + public function write($string); + + /** + * Returns whether or not the stream is readable. + * + * @return bool + */ + public function isReadable(); + + /** + * Read data from the stream. + * + * @param int $length Read up to $length bytes from the object and return + * them. Fewer than $length bytes may be returned if underlying stream + * call returns fewer bytes. + * @return string Returns the data read from the stream, or an empty string + * if no bytes are available. + * @throws \RuntimeException if an error occurs. + */ + public function read($length); + + /** + * Returns the remaining contents in a string + * + * @return string + * @throws \RuntimeException if unable to read or an error occurs while + * reading. + */ + public function getContents(); + + /** + * Get stream metadata as an associative array or retrieve a specific key. + * + * The keys returned are identical to the keys returned from PHP's + * stream_get_meta_data() function. + * + * @link http://php.net/manual/en/function.stream-get-meta-data.php + * @param string $key Specific metadata to retrieve. + * @return array|mixed|null Returns an associative array if no key is + * provided. Returns a specific key value if a key is provided and the + * value is found, or null if the key is not found. + */ + public function getMetadata($key = null); + } +} + +// file: vendor/psr/http-message/src/UploadedFileInterface.php +namespace Psr\Http\Message { + + /** + * Value object representing a file uploaded through an HTTP request. + * + * Instances of this interface are considered immutable; all methods that + * might change state MUST be implemented such that they retain the internal + * state of the current instance and return an instance that contains the + * changed state. + */ + interface UploadedFileInterface + { + /** + * Retrieve a stream representing the uploaded file. + * + * This method MUST return a StreamInterface instance, representing the + * uploaded file. The purpose of this method is to allow utilizing native PHP + * stream functionality to manipulate the file upload, such as + * stream_copy_to_stream() (though the result will need to be decorated in a + * native PHP stream wrapper to work with such functions). + * + * If the moveTo() method has been called previously, this method MUST raise + * an exception. + * + * @return StreamInterface Stream representation of the uploaded file. + * @throws \RuntimeException in cases when no stream is available or can be + * created. + */ + public function getStream(); + + /** + * Move the uploaded file to a new location. + * + * Use this method as an alternative to move_uploaded_file(). This method is + * guaranteed to work in both SAPI and non-SAPI environments. + * Implementations must determine which environment they are in, and use the + * appropriate method (move_uploaded_file(), rename(), or a stream + * operation) to perform the operation. + * + * $targetPath may be an absolute path, or a relative path. If it is a + * relative path, resolution should be the same as used by PHP's rename() + * function. + * + * The original file or stream MUST be removed on completion. + * + * If this method is called more than once, any subsequent calls MUST raise + * an exception. + * + * When used in an SAPI environment where $_FILES is populated, when writing + * files via moveTo(), is_uploaded_file() and move_uploaded_file() SHOULD be + * used to ensure permissions and upload status are verified correctly. + * + * If you wish to move to a stream, use getStream(), as SAPI operations + * cannot guarantee writing to stream destinations. + * + * @see http://php.net/is_uploaded_file + * @see http://php.net/move_uploaded_file + * @param string $targetPath Path to which to move the uploaded file. + * @throws \InvalidArgumentException if the $targetPath specified is invalid. + * @throws \RuntimeException on any error during the move operation, or on + * the second or subsequent call to the method. + */ + public function moveTo($targetPath); + + /** + * Retrieve the file size. + * + * Implementations SHOULD return the value stored in the "size" key of + * the file in the $_FILES array if available, as PHP calculates this based + * on the actual size transmitted. + * + * @return int|null The file size in bytes or null if unknown. + */ + public function getSize(); + + /** + * Retrieve the error associated with the uploaded file. + * + * The return value MUST be one of PHP's UPLOAD_ERR_XXX constants. + * + * If the file was uploaded successfully, this method MUST return + * UPLOAD_ERR_OK. + * + * Implementations SHOULD return the value stored in the "error" key of + * the file in the $_FILES array. + * + * @see http://php.net/manual/en/features.file-upload.errors.php + * @return int One of PHP's UPLOAD_ERR_XXX constants. + */ + public function getError(); + + /** + * Retrieve the filename sent by the client. + * + * Do not trust the value returned by this method. A client could send + * a malicious filename with the intention to corrupt or hack your + * application. + * + * Implementations SHOULD return the value stored in the "name" key of + * the file in the $_FILES array. + * + * @return string|null The filename sent by the client or null if none + * was provided. + */ + public function getClientFilename(); + + /** + * Retrieve the media type sent by the client. + * + * Do not trust the value returned by this method. A client could send + * a malicious media type with the intention to corrupt or hack your + * application. + * + * Implementations SHOULD return the value stored in the "type" key of + * the file in the $_FILES array. + * + * @return string|null The media type sent by the client or null if none + * was provided. + */ + public function getClientMediaType(); + } +} + +// file: vendor/psr/http-message/src/UriInterface.php +namespace Psr\Http\Message { + + /** + * Value object representing a URI. + * + * This interface is meant to represent URIs according to RFC 3986 and to + * provide methods for most common operations. Additional functionality for + * working with URIs can be provided on top of the interface or externally. + * Its primary use is for HTTP requests, but may also be used in other + * contexts. + * + * Instances of this interface are considered immutable; all methods that + * might change state MUST be implemented such that they retain the internal + * state of the current instance and return an instance that contains the + * changed state. + * + * Typically the Host header will be also be present in the request message. + * For server-side requests, the scheme will typically be discoverable in the + * server parameters. + * + * @link http://tools.ietf.org/html/rfc3986 (the URI specification) + */ + interface UriInterface + { + /** + * Retrieve the scheme component of the URI. + * + * If no scheme is present, this method MUST return an empty string. + * + * The value returned MUST be normalized to lowercase, per RFC 3986 + * Section 3.1. + * + * The trailing ":" character is not part of the scheme and MUST NOT be + * added. + * + * @see https://tools.ietf.org/html/rfc3986#section-3.1 + * @return string The URI scheme. + */ + public function getScheme(); + + /** + * Retrieve the authority component of the URI. + * + * If no authority information is present, this method MUST return an empty + * string. + * + * The authority syntax of the URI is: + * + *
+         * [user-info@]host[:port]
+         * 
+ * + * If the port component is not set or is the standard port for the current + * scheme, it SHOULD NOT be included. + * + * @see https://tools.ietf.org/html/rfc3986#section-3.2 + * @return string The URI authority, in "[user-info@]host[:port]" format. + */ + public function getAuthority(); + + /** + * Retrieve the user information component of the URI. + * + * If no user information is present, this method MUST return an empty + * string. + * + * If a user is present in the URI, this will return that value; + * additionally, if the password is also present, it will be appended to the + * user value, with a colon (":") separating the values. + * + * The trailing "@" character is not part of the user information and MUST + * NOT be added. + * + * @return string The URI user information, in "username[:password]" format. + */ + public function getUserInfo(); + + /** + * Retrieve the host component of the URI. + * + * If no host is present, this method MUST return an empty string. + * + * The value returned MUST be normalized to lowercase, per RFC 3986 + * Section 3.2.2. + * + * @see http://tools.ietf.org/html/rfc3986#section-3.2.2 + * @return string The URI host. + */ + public function getHost(); + + /** + * Retrieve the port component of the URI. + * + * If a port is present, and it is non-standard for the current scheme, + * this method MUST return it as an integer. If the port is the standard port + * used with the current scheme, this method SHOULD return null. + * + * If no port is present, and no scheme is present, this method MUST return + * a null value. + * + * If no port is present, but a scheme is present, this method MAY return + * the standard port for that scheme, but SHOULD return null. + * + * @return null|int The URI port. + */ + public function getPort(); + + /** + * Retrieve the path component of the URI. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * Normally, the empty path "" and absolute path "/" are considered equal as + * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically + * do this normalization because in contexts with a trimmed base path, e.g. + * the front controller, this difference becomes significant. It's the task + * of the user to handle both "" and "/". + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.3. + * + * As an example, if the value should include a slash ("/") not intended as + * delimiter between path segments, that value MUST be passed in encoded + * form (e.g., "%2F") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.3 + * @return string The URI path. + */ + public function getPath(); + + /** + * Retrieve the query string of the URI. + * + * If no query string is present, this method MUST return an empty string. + * + * The leading "?" character is not part of the query and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.4. + * + * As an example, if a value in a key/value pair of the query string should + * include an ampersand ("&") not intended as a delimiter between values, + * that value MUST be passed in encoded form (e.g., "%26") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.4 + * @return string The URI query string. + */ + public function getQuery(); + + /** + * Retrieve the fragment component of the URI. + * + * If no fragment is present, this method MUST return an empty string. + * + * The leading "#" character is not part of the fragment and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.5. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.5 + * @return string The URI fragment. + */ + public function getFragment(); + + /** + * Return an instance with the specified scheme. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified scheme. + * + * Implementations MUST support the schemes "http" and "https" case + * insensitively, and MAY accommodate other schemes if required. + * + * An empty scheme is equivalent to removing the scheme. + * + * @param string $scheme The scheme to use with the new instance. + * @return static A new instance with the specified scheme. + * @throws \InvalidArgumentException for invalid or unsupported schemes. + */ + public function withScheme($scheme); + + /** + * Return an instance with the specified user information. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified user information. + * + * Password is optional, but the user information MUST include the + * user; an empty string for the user is equivalent to removing user + * information. + * + * @param string $user The user name to use for authority. + * @param null|string $password The password associated with $user. + * @return static A new instance with the specified user information. + */ + public function withUserInfo($user, $password = null); + + /** + * Return an instance with the specified host. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified host. + * + * An empty host value is equivalent to removing the host. + * + * @param string $host The hostname to use with the new instance. + * @return static A new instance with the specified host. + * @throws \InvalidArgumentException for invalid hostnames. + */ + public function withHost($host); + + /** + * Return an instance with the specified port. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified port. + * + * Implementations MUST raise an exception for ports outside the + * established TCP and UDP port ranges. + * + * A null value provided for the port is equivalent to removing the port + * information. + * + * @param null|int $port The port to use with the new instance; a null value + * removes the port information. + * @return static A new instance with the specified port. + * @throws \InvalidArgumentException for invalid ports. + */ + public function withPort($port); + + /** + * Return an instance with the specified path. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified path. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * If the path is intended to be domain-relative rather than path relative then + * it must begin with a slash ("/"). Paths not starting with a slash ("/") + * are assumed to be relative to some base path known to the application or + * consumer. + * + * Users can provide both encoded and decoded path characters. + * Implementations ensure the correct encoding as outlined in getPath(). + * + * @param string $path The path to use with the new instance. + * @return static A new instance with the specified path. + * @throws \InvalidArgumentException for invalid paths. + */ + public function withPath($path); + + /** + * Return an instance with the specified query string. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified query string. + * + * Users can provide both encoded and decoded query characters. + * Implementations ensure the correct encoding as outlined in getQuery(). + * + * An empty query string value is equivalent to removing the query string. + * + * @param string $query The query string to use with the new instance. + * @return static A new instance with the specified query string. + * @throws \InvalidArgumentException for invalid query strings. + */ + public function withQuery($query); + + /** + * Return an instance with the specified URI fragment. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified URI fragment. + * + * Users can provide both encoded and decoded fragment characters. + * Implementations ensure the correct encoding as outlined in getFragment(). + * + * An empty fragment value is equivalent to removing the fragment. + * + * @param string $fragment The fragment to use with the new instance. + * @return static A new instance with the specified fragment. + */ + public function withFragment($fragment); + + /** + * Return the string representation as a URI reference. + * + * Depending on which components of the URI are present, the resulting + * string is either a full URI or relative reference according to RFC 3986, + * Section 4.1. The method concatenates the various components of the URI, + * using the appropriate delimiters: + * + * - If a scheme is present, it MUST be suffixed by ":". + * - If an authority is present, it MUST be prefixed by "//". + * - The path can be concatenated without delimiters. But there are two + * cases where the path has to be adjusted to make the URI reference + * valid as PHP does not allow to throw an exception in __toString(): + * - If the path is rootless and an authority is present, the path MUST + * be prefixed by "/". + * - If the path is starting with more than one "/" and no authority is + * present, the starting slashes MUST be reduced to one. + * - If a query is present, it MUST be prefixed by "?". + * - If a fragment is present, it MUST be prefixed by "#". + * + * @see http://tools.ietf.org/html/rfc3986#section-4.1 + * @return string + */ + public function __toString(); + } +} + +// file: vendor/psr/http-server-handler/src/RequestHandlerInterface.php +namespace Psr\Http\Server { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + + /** + * Handles a server request and produces a response. + * + * An HTTP request handler process an HTTP request in order to produce an + * HTTP response. + */ + interface RequestHandlerInterface + { + /** + * Handles a request and produces a response. + * + * May call other collaborating code to generate the response. + */ + public function handle(ServerRequestInterface $request): ResponseInterface; + } +} + +// file: vendor/psr/http-server-middleware/src/MiddlewareInterface.php +namespace Psr\Http\Server { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + + /** + * Participant in processing a server request and response. + * + * An HTTP middleware component participates in processing an HTTP message: + * by acting on the request, generating the response, or forwarding the + * request to a subsequent middleware and possibly acting on its response. + */ + interface MiddlewareInterface + { + /** + * Process an incoming server request. + * + * Processes an incoming server request in order to produce a response. + * If unable to produce the response itself, it may delegate to the provided + * request handler to do so. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; + } +} + +// file: vendor/nyholm/psr7/src/Factory/Psr17Factory.php +namespace Nyholm\Psr7\Factory { + + use Nyholm\Psr7\{Request, Response, ServerRequest, Stream, UploadedFile, Uri}; + use Psr\Http\Message\{RequestFactoryInterface, RequestInterface, ResponseFactoryInterface, ResponseInterface, ServerRequestFactoryInterface, ServerRequestInterface, StreamFactoryInterface, StreamInterface, UploadedFileFactoryInterface, UploadedFileInterface, UriFactoryInterface, UriInterface}; + + /** + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface + { + public function createRequest(string $method, $uri): RequestInterface + { + return new Request($method, $uri); + } + + public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface + { + if (2 > \func_num_args()) { + // This will make the Response class to use a custom reasonPhrase + $reasonPhrase = null; + } + + return new Response($code, [], null, '1.1', $reasonPhrase); + } + + public function createStream(string $content = ''): StreamInterface + { + return Stream::create($content); + } + + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + $resource = @\fopen($filename, $mode); + if (false === $resource) { + if ('' === $mode || false === \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'])) { + throw new \InvalidArgumentException('The mode ' . $mode . ' is invalid.'); + } + + throw new \RuntimeException('The file ' . $filename . ' cannot be opened.'); + } + + return Stream::create($resource); + } + + public function createStreamFromResource($resource): StreamInterface + { + return Stream::create($resource); + } + + public function createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null): UploadedFileInterface + { + if (null === $size) { + $size = $stream->getSize(); + } + + return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType); + } + + public function createUri(string $uri = ''): UriInterface + { + return new Uri($uri); + } + + public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface + { + return new ServerRequest($method, $uri, [], null, '1.1', $serverParams); + } + } +} + +// file: vendor/nyholm/psr7/src/MessageTrait.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\StreamInterface; + + /** + * Trait implementing functionality common to requests and responses. + * + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + * + * @internal should not be used outside of Nyholm/Psr7 as it does not fall under our BC promise + */ + trait MessageTrait + { + /** @var array Map of all registered headers, as original name => array of values */ + private $headers = []; + + /** @var array Map of lowercase header name => original name at registration */ + private $headerNames = []; + + /** @var string */ + private $protocol = '1.1'; + + /** @var StreamInterface|null */ + private $stream; + + public function getProtocolVersion(): string + { + return $this->protocol; + } + + public function withProtocolVersion($version): self + { + if ($this->protocol === $version) { + return $this; + } + + $new = clone $this; + $new->protocol = $version; + + return $new; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function hasHeader($header): bool + { + return isset($this->headerNames[\strtolower($header)]); + } + + public function getHeader($header): array + { + $header = \strtolower($header); + if (!isset($this->headerNames[$header])) { + return []; + } + + $header = $this->headerNames[$header]; + + return $this->headers[$header]; + } + + public function getHeaderLine($header): string + { + return \implode(', ', $this->getHeader($header)); + } + + public function withHeader($header, $value): self + { + $value = $this->validateAndTrimHeader($header, $value); + $normalized = \strtolower($header); + + $new = clone $this; + if (isset($new->headerNames[$normalized])) { + unset($new->headers[$new->headerNames[$normalized]]); + } + $new->headerNames[$normalized] = $header; + $new->headers[$header] = $value; + + return $new; + } + + public function withAddedHeader($header, $value): self + { + if (!\is_string($header) || '' === $header) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + } + + $new = clone $this; + $new->setHeaders([$header => $value]); + + return $new; + } + + public function withoutHeader($header): self + { + $normalized = \strtolower($header); + if (!isset($this->headerNames[$normalized])) { + return $this; + } + + $header = $this->headerNames[$normalized]; + $new = clone $this; + unset($new->headers[$header], $new->headerNames[$normalized]); + + return $new; + } + + public function getBody(): StreamInterface + { + if (null === $this->stream) { + $this->stream = Stream::create(''); + } + + return $this->stream; + } + + public function withBody(StreamInterface $body): self + { + if ($body === $this->stream) { + return $this; + } + + $new = clone $this; + $new->stream = $body; + + return $new; + } + + private function setHeaders(array $headers) /*:void*/ + { + foreach ($headers as $header => $value) { + $value = $this->validateAndTrimHeader($header, $value); + $normalized = \strtolower($header); + if (isset($this->headerNames[$normalized])) { + $header = $this->headerNames[$normalized]; + $this->headers[$header] = \array_merge($this->headers[$header], $value); + } else { + $this->headerNames[$normalized] = $header; + $this->headers[$header] = $value; + } + } + } + + /** + * Make sure the header complies with RFC 7230. + * + * Header names must be a non-empty string consisting of token characters. + * + * Header values must be strings consisting of visible characters with all optional + * leading and trailing whitespace stripped. This method will always strip such + * optional whitespace. Note that the method does not allow folding whitespace within + * the values as this was deprecated for almost all instances by the RFC. + * + * header-field = field-name ":" OWS field-value OWS + * field-name = 1*( "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" + * / "_" / "`" / "|" / "~" / %x30-39 / ( %x41-5A / %x61-7A ) ) + * OWS = *( SP / HTAB ) + * field-value = *( ( %x21-7E / %x80-FF ) [ 1*( SP / HTAB ) ( %x21-7E / %x80-FF ) ] ) + * + * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 + */ + private function validateAndTrimHeader($header, $values): array + { + if (!\is_string($header) || 1 !== \preg_match("@^[!#$%&'*+.^_`|~0-9A-Za-z-]+$@", $header)) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + } + + if (!\is_array($values)) { + // This is simple, just one value. + if ((!\is_numeric($values) && !\is_string($values)) || 1 !== \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $values)) { + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); + } + + return [\trim((string) $values, " \t")]; + } + + if (empty($values)) { + throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given.'); + } + + // Assert Non empty array + $returnValues = []; + foreach ($values as $v) { + if ((!\is_numeric($v) && !\is_string($v)) || 1 !== \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $v)) { + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); + } + + $returnValues[] = \trim((string) $v, " \t"); + } + + return $returnValues; + } + } +} + +// file: vendor/nyholm/psr7/src/Request.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\{RequestInterface, StreamInterface, UriInterface}; + + /** + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Request implements RequestInterface + { + use MessageTrait; + use RequestTrait; + + /** + * @param string $method HTTP method + * @param string|UriInterface $uri URI + * @param array $headers Request headers + * @param string|resource|StreamInterface|null $body Request body + * @param string $version Protocol version + */ + public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1') + { + if (!($uri instanceof UriInterface)) { + $uri = new Uri($uri); + } + + $this->method = $method; + $this->uri = $uri; + $this->setHeaders($headers); + $this->protocol = $version; + + if (!$this->hasHeader('Host')) { + $this->updateHostFromUri(); + } + + // If we got no body, defer initialization of the stream until Request::getBody() + if ('' !== $body && null !== $body) { + $this->stream = Stream::create($body); + } + } + } +} + +// file: vendor/nyholm/psr7/src/RequestTrait.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\UriInterface; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + * + * @internal should not be used outside of Nyholm/Psr7 as it does not fall under our BC promise + */ + trait RequestTrait + { + /** @var string */ + private $method; + + /** @var string|null */ + private $requestTarget; + + /** @var UriInterface|null */ + private $uri; + + public function getRequestTarget(): string + { + if (null !== $this->requestTarget) { + return $this->requestTarget; + } + + if ('' === $target = $this->uri->getPath()) { + $target = '/'; + } + if ('' !== $this->uri->getQuery()) { + $target .= '?' . $this->uri->getQuery(); + } + + return $target; + } + + public function withRequestTarget($requestTarget): self + { + if (\preg_match('#\s#', $requestTarget)) { + throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace'); + } + + $new = clone $this; + $new->requestTarget = $requestTarget; + + return $new; + } + + public function getMethod(): string + { + return $this->method; + } + + public function withMethod($method): self + { + if (!\is_string($method)) { + throw new \InvalidArgumentException('Method must be a string'); + } + + $new = clone $this; + $new->method = $method; + + return $new; + } + + public function getUri(): UriInterface + { + return $this->uri; + } + + public function withUri(UriInterface $uri, $preserveHost = false): self + { + if ($uri === $this->uri) { + return $this; + } + + $new = clone $this; + $new->uri = $uri; + + if (!$preserveHost || !$this->hasHeader('Host')) { + $new->updateHostFromUri(); + } + + return $new; + } + + private function updateHostFromUri() /*:void*/ + { + if ('' === $host = $this->uri->getHost()) { + return; + } + + if (null !== ($port = $this->uri->getPort())) { + $host .= ':' . $port; + } + + if (isset($this->headerNames['host'])) { + $header = $this->headerNames['host']; + } else { + $this->headerNames['host'] = $header = 'Host'; + } + + // Ensure Host is the first header. + // See: http://tools.ietf.org/html/rfc7230#section-5.4 + $this->headers = [$header => [$host]] + $this->headers; + } + } +} + +// file: vendor/nyholm/psr7/src/Response.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\{ResponseInterface, StreamInterface}; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Response implements ResponseInterface + { + use MessageTrait; + + /** @var array Map of standard HTTP status code/reason phrases */ + /*private*/ const PHRASES = [ + 100 => 'Continue', 101 => 'Switching Protocols', 102 => 'Processing', + 200 => 'OK', 201 => 'Created', 202 => 'Accepted', 203 => 'Non-Authoritative Information', 204 => 'No Content', 205 => 'Reset Content', 206 => 'Partial Content', 207 => 'Multi-status', 208 => 'Already Reported', + 300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 305 => 'Use Proxy', 306 => 'Switch Proxy', 307 => 'Temporary Redirect', + 400 => 'Bad Request', 401 => 'Unauthorized', 402 => 'Payment Required', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 406 => 'Not Acceptable', 407 => 'Proxy Authentication Required', 408 => 'Request Time-out', 409 => 'Conflict', 410 => 'Gone', 411 => 'Length Required', 412 => 'Precondition Failed', 413 => 'Request Entity Too Large', 414 => 'Request-URI Too Large', 415 => 'Unsupported Media Type', 416 => 'Requested range not satisfiable', 417 => 'Expectation Failed', 418 => 'I\'m a teapot', 422 => 'Unprocessable Entity', 423 => 'Locked', 424 => 'Failed Dependency', 425 => 'Unordered Collection', 426 => 'Upgrade Required', 428 => 'Precondition Required', 429 => 'Too Many Requests', 431 => 'Request Header Fields Too Large', 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Time-out', 505 => 'HTTP Version not supported', 506 => 'Variant Also Negotiates', 507 => 'Insufficient Storage', 508 => 'Loop Detected', 511 => 'Network Authentication Required', + ]; + + /** @var string */ + private $reasonPhrase = ''; + + /** @var int */ + private $statusCode; + + /** + * @param int $status Status code + * @param array $headers Response headers + * @param string|resource|StreamInterface|null $body Response body + * @param string $version Protocol version + * @param string|null $reason Reason phrase (when empty a default will be used based on the status code) + */ + public function __construct(int $status = 200, array $headers = [], $body = null, string $version = '1.1', string $reason = null) + { + // If we got no body, defer initialization of the stream until Response::getBody() + if ('' !== $body && null !== $body) { + $this->stream = Stream::create($body); + } + + $this->statusCode = $status; + $this->setHeaders($headers); + if (null === $reason && isset(self::PHRASES[$this->statusCode])) { + $this->reasonPhrase = self::PHRASES[$status]; + } else { + $this->reasonPhrase = $reason ?? ''; + } + + $this->protocol = $version; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getReasonPhrase(): string + { + return $this->reasonPhrase; + } + + public function withStatus($code, $reasonPhrase = ''): self + { + if (!\is_int($code) && !\is_string($code)) { + throw new \InvalidArgumentException('Status code has to be an integer'); + } + + $code = (int) $code; + if ($code < 100 || $code > 599) { + throw new \InvalidArgumentException('Status code has to be an integer between 100 and 599'); + } + + $new = clone $this; + $new->statusCode = $code; + if ((null === $reasonPhrase || '' === $reasonPhrase) && isset(self::PHRASES[$new->statusCode])) { + $reasonPhrase = self::PHRASES[$new->statusCode]; + } + $new->reasonPhrase = $reasonPhrase; + + return $new; + } + } +} + +// file: vendor/nyholm/psr7/src/ServerRequest.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\{ServerRequestInterface, StreamInterface, UploadedFileInterface, UriInterface}; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class ServerRequest implements ServerRequestInterface + { + use MessageTrait; + use RequestTrait; + + /** @var array */ + private $attributes = []; + + /** @var array */ + private $cookieParams = []; + + /** @var array|object|null */ + private $parsedBody; + + /** @var array */ + private $queryParams = []; + + /** @var array */ + private $serverParams; + + /** @var UploadedFileInterface[] */ + private $uploadedFiles = []; + + /** + * @param string $method HTTP method + * @param string|UriInterface $uri URI + * @param array $headers Request headers + * @param string|resource|StreamInterface|null $body Request body + * @param string $version Protocol version + * @param array $serverParams Typically the $_SERVER superglobal + */ + public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1', array $serverParams = []) + { + $this->serverParams = $serverParams; + + if (!($uri instanceof UriInterface)) { + $uri = new Uri($uri); + } + + $this->method = $method; + $this->uri = $uri; + $this->setHeaders($headers); + $this->protocol = $version; + + if (!$this->hasHeader('Host')) { + $this->updateHostFromUri(); + } + + // If we got no body, defer initialization of the stream until ServerRequest::getBody() + if ('' !== $body && null !== $body) { + $this->stream = Stream::create($body); + } + } + + public function getServerParams(): array + { + return $this->serverParams; + } + + public function getUploadedFiles(): array + { + return $this->uploadedFiles; + } + + public function withUploadedFiles(array $uploadedFiles) + { + $new = clone $this; + $new->uploadedFiles = $uploadedFiles; + + return $new; + } + + public function getCookieParams(): array + { + return $this->cookieParams; + } + + public function withCookieParams(array $cookies) + { + $new = clone $this; + $new->cookieParams = $cookies; + + return $new; + } + + public function getQueryParams(): array + { + return $this->queryParams; + } + + public function withQueryParams(array $query) + { + $new = clone $this; + $new->queryParams = $query; + + return $new; + } + + public function getParsedBody() + { + return $this->parsedBody; + } + + public function withParsedBody($data) + { + if (!\is_array($data) && !\is_object($data) && null !== $data) { + throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null'); + } + + $new = clone $this; + $new->parsedBody = $data; + + return $new; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttribute($attribute, $default = null) + { + if (false === \array_key_exists($attribute, $this->attributes)) { + return $default; + } + + return $this->attributes[$attribute]; + } + + public function withAttribute($attribute, $value): self + { + $new = clone $this; + $new->attributes[$attribute] = $value; + + return $new; + } + + public function withoutAttribute($attribute): self + { + if (false === \array_key_exists($attribute, $this->attributes)) { + return $this; + } + + $new = clone $this; + unset($new->attributes[$attribute]); + + return $new; + } + } +} + +// file: vendor/nyholm/psr7/src/Stream.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\StreamInterface; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Stream implements StreamInterface + { + /** @var resource|null A resource reference */ + private $stream; + + /** @var bool */ + private $seekable; + + /** @var bool */ + private $readable; + + /** @var bool */ + private $writable; + + /** @var array|mixed|void|null */ + private $uri; + + /** @var int|null */ + private $size; + + /** @var array Hash of readable and writable stream types */ + /*private*/ const READ_WRITE_HASH = [ + 'read' => [ + 'r' => true, 'w+' => true, 'r+' => true, 'x+' => true, 'c+' => true, + 'rb' => true, 'w+b' => true, 'r+b' => true, 'x+b' => true, + 'c+b' => true, 'rt' => true, 'w+t' => true, 'r+t' => true, + 'x+t' => true, 'c+t' => true, 'a+' => true, + ], + 'write' => [ + 'w' => true, 'w+' => true, 'rw' => true, 'r+' => true, 'x+' => true, + 'c+' => true, 'wb' => true, 'w+b' => true, 'r+b' => true, + 'x+b' => true, 'c+b' => true, 'w+t' => true, 'r+t' => true, + 'x+t' => true, 'c+t' => true, 'a' => true, 'a+' => true, + ], + ]; + + private function __construct() + { + } + + /** + * Creates a new PSR-7 stream. + * + * @param string|resource|StreamInterface $body + * + * @return StreamInterface + * + * @throws \InvalidArgumentException + */ + public static function create($body = ''): StreamInterface + { + if ($body instanceof StreamInterface) { + return $body; + } + + if (\is_string($body)) { + $resource = \fopen('php://temp', 'rw+'); + \fwrite($resource, $body); + $body = $resource; + } + + if (\is_resource($body)) { + $new = new self(); + $new->stream = $body; + $meta = \stream_get_meta_data($new->stream); + $new->seekable = $meta['seekable'] && 0 === \fseek($new->stream, 0, \SEEK_CUR); + $new->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); + $new->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); + $new->uri = $new->getMetadata('uri'); + + return $new; + } + + throw new \InvalidArgumentException('First argument to Stream::create() must be a string, resource or StreamInterface.'); + } + + /** + * Closes the stream when the destructed. + */ + public function __destruct() + { + $this->close(); + } + + public function __toString(): string + { + try { + if ($this->isSeekable()) { + $this->seek(0); + } + + return $this->getContents(); + } catch (\Exception $e) { + return ''; + } + } + + public function close() /*:void*/ + { + if (isset($this->stream)) { + if (\is_resource($this->stream)) { + \fclose($this->stream); + } + $this->detach(); + } + } + + public function detach() + { + if (!isset($this->stream)) { + return null; + } + + $result = $this->stream; + unset($this->stream); + $this->size = $this->uri = null; + $this->readable = $this->writable = $this->seekable = false; + + return $result; + } + + public function getSize() /*:?int*/ + { + if (null !== $this->size) { + return $this->size; + } + + if (!isset($this->stream)) { + return null; + } + + // Clear the stat cache if the stream has a URI + if ($this->uri) { + \clearstatcache(true, $this->uri); + } + + $stats = \fstat($this->stream); + if (isset($stats['size'])) { + $this->size = $stats['size']; + + return $this->size; + } + + return null; + } + + public function tell(): int + { + if (false === $result = \ftell($this->stream)) { + throw new \RuntimeException('Unable to determine stream position'); + } + + return $result; + } + + public function eof(): bool + { + return !$this->stream || \feof($this->stream); + } + + public function isSeekable(): bool + { + return $this->seekable; + } + + public function seek($offset, $whence = \SEEK_SET) /*:void*/ + { + if (!$this->seekable) { + throw new \RuntimeException('Stream is not seekable'); + } + + if (-1 === \fseek($this->stream, $offset, $whence)) { + throw new \RuntimeException('Unable to seek to stream position ' . $offset . ' with whence ' . \var_export($whence, true)); + } + } + + public function rewind() /*:void*/ + { + $this->seek(0); + } + + public function isWritable(): bool + { + return $this->writable; + } + + public function write($string): int + { + if (!$this->writable) { + throw new \RuntimeException('Cannot write to a non-writable stream'); + } + + // We can't know the size after writing anything + $this->size = null; + + if (false === $result = \fwrite($this->stream, $string)) { + throw new \RuntimeException('Unable to write to stream'); + } + + return $result; + } + + public function isReadable(): bool + { + return $this->readable; + } + + public function read($length): string + { + if (!$this->readable) { + throw new \RuntimeException('Cannot read from non-readable stream'); + } + + return \fread($this->stream, $length); + } + + public function getContents(): string + { + if (!isset($this->stream)) { + throw new \RuntimeException('Unable to read stream contents'); + } + + if (false === $contents = \stream_get_contents($this->stream)) { + throw new \RuntimeException('Unable to read stream contents'); + } + + return $contents; + } + + public function getMetadata($key = null) + { + if (!isset($this->stream)) { + return $key ? null : []; + } + + $meta = \stream_get_meta_data($this->stream); + + if (null === $key) { + return $meta; + } + + return $meta[$key] ?? null; + } + } +} + +// file: vendor/nyholm/psr7/src/UploadedFile.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\{StreamInterface, UploadedFileInterface}; + + /** + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class UploadedFile implements UploadedFileInterface + { + /** @var array */ + /*private*/ const ERRORS = [ + \UPLOAD_ERR_OK => 1, + \UPLOAD_ERR_INI_SIZE => 1, + \UPLOAD_ERR_FORM_SIZE => 1, + \UPLOAD_ERR_PARTIAL => 1, + \UPLOAD_ERR_NO_FILE => 1, + \UPLOAD_ERR_NO_TMP_DIR => 1, + \UPLOAD_ERR_CANT_WRITE => 1, + \UPLOAD_ERR_EXTENSION => 1, + ]; + + /** @var string */ + private $clientFilename; + + /** @var string */ + private $clientMediaType; + + /** @var int */ + private $error; + + /** @var string|null */ + private $file; + + /** @var bool */ + private $moved = false; + + /** @var int */ + private $size; + + /** @var StreamInterface|null */ + private $stream; + + /** + * @param StreamInterface|string|resource $streamOrFile + * @param int $size + * @param int $errorStatus + * @param string|null $clientFilename + * @param string|null $clientMediaType + */ + public function __construct($streamOrFile, $size, $errorStatus, $clientFilename = null, $clientMediaType = null) + { + if (false === \is_int($errorStatus) || !isset(self::ERRORS[$errorStatus])) { + throw new \InvalidArgumentException('Upload file error status must be an integer value and one of the "UPLOAD_ERR_*" constants.'); + } + + if (false === \is_int($size)) { + throw new \InvalidArgumentException('Upload file size must be an integer'); + } + + if (null !== $clientFilename && !\is_string($clientFilename)) { + throw new \InvalidArgumentException('Upload file client filename must be a string or null'); + } + + if (null !== $clientMediaType && !\is_string($clientMediaType)) { + throw new \InvalidArgumentException('Upload file client media type must be a string or null'); + } + + $this->error = $errorStatus; + $this->size = $size; + $this->clientFilename = $clientFilename; + $this->clientMediaType = $clientMediaType; + + if (\UPLOAD_ERR_OK === $this->error) { + // Depending on the value set file or stream variable. + if (\is_string($streamOrFile)) { + $this->file = $streamOrFile; + } elseif (\is_resource($streamOrFile)) { + $this->stream = Stream::create($streamOrFile); + } elseif ($streamOrFile instanceof StreamInterface) { + $this->stream = $streamOrFile; + } else { + throw new \InvalidArgumentException('Invalid stream or file provided for UploadedFile'); + } + } + } + + /** + * @throws \RuntimeException if is moved or not ok + */ + private function validateActive() /*:void*/ + { + if (\UPLOAD_ERR_OK !== $this->error) { + throw new \RuntimeException('Cannot retrieve stream due to upload error'); + } + + if ($this->moved) { + throw new \RuntimeException('Cannot retrieve stream after it has already been moved'); + } + } + + public function getStream(): StreamInterface + { + $this->validateActive(); + + if ($this->stream instanceof StreamInterface) { + return $this->stream; + } + + $resource = \fopen($this->file, 'r'); + + return Stream::create($resource); + } + + public function moveTo($targetPath) /*:void*/ + { + $this->validateActive(); + + if (!\is_string($targetPath) || '' === $targetPath) { + throw new \InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + } + + if (null !== $this->file) { + $this->moved = 'cli' === \PHP_SAPI ? \rename($this->file, $targetPath) : \move_uploaded_file($this->file, $targetPath); + } else { + $stream = $this->getStream(); + if ($stream->isSeekable()) { + $stream->rewind(); + } + + // Copy the contents of a stream into another stream until end-of-file. + $dest = Stream::create(\fopen($targetPath, 'w')); + while (!$stream->eof()) { + if (!$dest->write($stream->read(1048576))) { + break; + } + } + + $this->moved = true; + } + + if (false === $this->moved) { + throw new \RuntimeException(\sprintf('Uploaded file could not be moved to %s', $targetPath)); + } + } + + public function getSize(): int + { + return $this->size; + } + + public function getError(): int + { + return $this->error; + } + + public function getClientFilename() /*:?string*/ + { + return $this->clientFilename; + } + + public function getClientMediaType() /*:?string*/ + { + return $this->clientMediaType; + } + } +} + +// file: vendor/nyholm/psr7/src/Uri.php +namespace Nyholm\Psr7 { + + use Psr\Http\Message\UriInterface; + + /** + * PSR-7 URI implementation. + * + * @author Michael Dowling + * @author Tobias Schultze + * @author Matthew Weier O'Phinney + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class Uri implements UriInterface + { + /*private*/ const SCHEMES = ['http' => 80, 'https' => 443]; + + /*private*/ const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; + + /*private*/ const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; + + /** @var string Uri scheme. */ + private $scheme = ''; + + /** @var string Uri user info. */ + private $userInfo = ''; + + /** @var string Uri host. */ + private $host = ''; + + /** @var int|null Uri port. */ + private $port; + + /** @var string Uri path. */ + private $path = ''; + + /** @var string Uri query string. */ + private $query = ''; + + /** @var string Uri fragment. */ + private $fragment = ''; + + public function __construct(string $uri = '') + { + if ('' !== $uri) { + if (false === $parts = \parse_url($uri)) { + throw new \InvalidArgumentException("Unable to parse URI: $uri"); + } + + // Apply parse_url parts to a URI. + $this->scheme = isset($parts['scheme']) ? \strtolower($parts['scheme']) : ''; + $this->userInfo = $parts['user'] ?? ''; + $this->host = isset($parts['host']) ? \strtolower($parts['host']) : ''; + $this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null; + $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; + $this->query = isset($parts['query']) ? $this->filterQueryAndFragment($parts['query']) : ''; + $this->fragment = isset($parts['fragment']) ? $this->filterQueryAndFragment($parts['fragment']) : ''; + if (isset($parts['pass'])) { + $this->userInfo .= ':' . $parts['pass']; + } + } + } + + public function __toString(): string + { + return self::createUriString($this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment); + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getAuthority(): string + { + if ('' === $this->host) { + return ''; + } + + $authority = $this->host; + if ('' !== $this->userInfo) { + $authority = $this->userInfo . '@' . $authority; + } + + if (null !== $this->port) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + public function getUserInfo(): string + { + return $this->userInfo; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort() /*:?int*/ + { + return $this->port; + } + + public function getPath(): string + { + return $this->path; + } + + public function getQuery(): string + { + return $this->query; + } + + public function getFragment(): string + { + return $this->fragment; + } + + public function withScheme($scheme): self + { + if (!\is_string($scheme)) { + throw new \InvalidArgumentException('Scheme must be a string'); + } + + if ($this->scheme === $scheme = \strtolower($scheme)) { + return $this; + } + + $new = clone $this; + $new->scheme = $scheme; + $new->port = $new->filterPort($new->port); + + return $new; + } + + public function withUserInfo($user, $password = null): self + { + $info = $user; + if (null !== $password && '' !== $password) { + $info .= ':' . $password; + } + + if ($this->userInfo === $info) { + return $this; + } + + $new = clone $this; + $new->userInfo = $info; + + return $new; + } + + public function withHost($host): self + { + if (!\is_string($host)) { + throw new \InvalidArgumentException('Host must be a string'); + } + + if ($this->host === $host = \strtolower($host)) { + return $this; + } + + $new = clone $this; + $new->host = $host; + + return $new; + } + + public function withPort($port): self + { + if ($this->port === $port = $this->filterPort($port)) { + return $this; + } + + $new = clone $this; + $new->port = $port; + + return $new; + } + + public function withPath($path): self + { + if ($this->path === $path = $this->filterPath($path)) { + return $this; + } + + $new = clone $this; + $new->path = $path; + + return $new; + } + + public function withQuery($query): self + { + if ($this->query === $query = $this->filterQueryAndFragment($query)) { + return $this; + } + + $new = clone $this; + $new->query = $query; + + return $new; + } + + public function withFragment($fragment): self + { + if ($this->fragment === $fragment = $this->filterQueryAndFragment($fragment)) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + /** + * Create a URI string from its various parts. + */ + private static function createUriString(string $scheme, string $authority, string $path, string $query, string $fragment): string + { + $uri = ''; + if ('' !== $scheme) { + $uri .= $scheme . ':'; + } + + if ('' !== $authority) { + $uri .= '//' . $authority; + } + + if ('' !== $path) { + if ('/' !== $path[0]) { + if ('' !== $authority) { + // If the path is rootless and an authority is present, the path MUST be prefixed by "/" + $path = '/' . $path; + } + } elseif (isset($path[1]) && '/' === $path[1]) { + if ('' === $authority) { + // If the path is starting with more than one "/" and no authority is present, the + // starting slashes MUST be reduced to one. + $path = '/' . \ltrim($path, '/'); + } + } + + $uri .= $path; + } + + if ('' !== $query) { + $uri .= '?' . $query; + } + + if ('' !== $fragment) { + $uri .= '#' . $fragment; + } + + return $uri; + } + + /** + * Is a given port non-standard for the current scheme? + */ + private static function isNonStandardPort(string $scheme, int $port): bool + { + return !isset(self::SCHEMES[$scheme]) || $port !== self::SCHEMES[$scheme]; + } + + private function filterPort($port) /*:?int*/ + { + if (null === $port) { + return null; + } + + $port = (int) $port; + if (0 > $port || 0xffff < $port) { + throw new \InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port)); + } + + return self::isNonStandardPort($this->scheme, $port) ? $port : null; + } + + private function filterPath($path): string + { + if (!\is_string($path)) { + throw new \InvalidArgumentException('Path must be a string'); + } + + return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $path); + } + + private function filterQueryAndFragment($str): string + { + if (!\is_string($str)) { + throw new \InvalidArgumentException('Query and fragment must be a string'); + } + + return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $str); + } + + private static function rawurlencodeMatchZero(array $match): string + { + return \rawurlencode($match[0]); + } + } +} + +// file: vendor/nyholm/psr7-server/src/ServerRequestCreator.php +namespace Nyholm\Psr7Server { + + use Psr\Http\Message\ServerRequestFactoryInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Message\StreamFactoryInterface; + use Psr\Http\Message\StreamInterface; + use Psr\Http\Message\UploadedFileFactoryInterface; + use Psr\Http\Message\UploadedFileInterface; + use Psr\Http\Message\UriFactoryInterface; + use Psr\Http\Message\UriInterface; + + /** + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + final class ServerRequestCreator implements ServerRequestCreatorInterface + { + private $serverRequestFactory; + + private $uriFactory; + + private $uploadedFileFactory; + + private $streamFactory; + + public function __construct( + ServerRequestFactoryInterface $serverRequestFactory, + UriFactoryInterface $uriFactory, + UploadedFileFactoryInterface $uploadedFileFactory, + StreamFactoryInterface $streamFactory + ) { + $this->serverRequestFactory = $serverRequestFactory; + $this->uriFactory = $uriFactory; + $this->uploadedFileFactory = $uploadedFileFactory; + $this->streamFactory = $streamFactory; + } + + /** + * {@inheritdoc} + */ + public function fromGlobals(): ServerRequestInterface + { + $server = $_SERVER; + if (false === isset($server['REQUEST_METHOD'])) { + $server['REQUEST_METHOD'] = 'GET'; + } + + $headers = \function_exists('getallheaders') ? getallheaders() : static::getHeadersFromServer($_SERVER); + + return $this->fromArrays($server, $headers, $_COOKIE, $_GET, $_POST, $_FILES, \fopen('php://input', 'r') ?: null); + } + + /** + * {@inheritdoc} + */ + public function fromArrays(array $server, array $headers = [], array $cookie = [], array $get = [], array $post = [], array $files = [], $body = null): ServerRequestInterface + { + $method = $this->getMethodFromEnv($server); + $uri = $this->getUriFromEnvWithHTTP($server); + $protocol = isset($server['SERVER_PROTOCOL']) ? \str_replace('HTTP/', '', $server['SERVER_PROTOCOL']) : '1.1'; + + $serverRequest = $this->serverRequestFactory->createServerRequest($method, $uri, $server); + foreach ($headers as $name => $value) { + $serverRequest = $serverRequest->withAddedHeader($name, $value); + } + + $serverRequest = $serverRequest + ->withProtocolVersion($protocol) + ->withCookieParams($cookie) + ->withQueryParams($get) + ->withParsedBody($post) + ->withUploadedFiles($this->normalizeFiles($files)); + + if (null === $body) { + return $serverRequest; + } + + if (\is_resource($body)) { + $body = $this->streamFactory->createStreamFromResource($body); + } elseif (\is_string($body)) { + $body = $this->streamFactory->createStream($body); + } elseif (!$body instanceof StreamInterface) { + throw new \InvalidArgumentException('The $body parameter to ServerRequestCreator::fromArrays must be string, resource or StreamInterface'); + } + + return $serverRequest->withBody($body); + } + + /** + * Implementation from Zend\Diactoros\marshalHeadersFromSapi(). + */ + public static function getHeadersFromServer(array $server): array + { + $headers = []; + foreach ($server as $key => $value) { + // Apache prefixes environment variables with REDIRECT_ + // if they are added by rewrite rules + if (0 === \strpos($key, 'REDIRECT_')) { + $key = \substr($key, 9); + + // We will not overwrite existing variables with the + // prefixed versions, though + if (\array_key_exists($key, $server)) { + continue; + } + } + + if ($value && 0 === \strpos($key, 'HTTP_')) { + $name = \strtr(\strtolower(\substr($key, 5)), '_', '-'); + $headers[$name] = $value; + + continue; + } + + if ($value && 0 === \strpos($key, 'CONTENT_')) { + $name = 'content-'.\strtolower(\substr($key, 8)); + $headers[$name] = $value; + + continue; + } + } + + return $headers; + } + + private function getMethodFromEnv(array $environment): string + { + if (false === isset($environment['REQUEST_METHOD'])) { + throw new \InvalidArgumentException('Cannot determine HTTP method'); + } + + return $environment['REQUEST_METHOD']; + } + + private function getUriFromEnvWithHTTP(array $environment): UriInterface + { + $uri = $this->createUriFromArray($environment); + if (empty($uri->getScheme())) { + $uri = $uri->withScheme('http'); + } + + return $uri; + } + + /** + * Return an UploadedFile instance array. + * + * @param array $files A array which respect $_FILES structure + * + * @return UploadedFileInterface[] + * + * @throws \InvalidArgumentException for unrecognized values + */ + private function normalizeFiles(array $files): array + { + $normalized = []; + + foreach ($files as $key => $value) { + if ($value instanceof UploadedFileInterface) { + $normalized[$key] = $value; + } elseif (\is_array($value) && isset($value['tmp_name'])) { + $normalized[$key] = $this->createUploadedFileFromSpec($value); + } elseif (\is_array($value)) { + $normalized[$key] = $this->normalizeFiles($value); + } else { + throw new \InvalidArgumentException('Invalid value in files specification'); + } + } + + return $normalized; + } + + /** + * Create and return an UploadedFile instance from a $_FILES specification. + * + * If the specification represents an array of values, this method will + * delegate to normalizeNestedFileSpec() and return that return value. + * + * @param array $value $_FILES struct + * + * @return array|UploadedFileInterface + */ + private function createUploadedFileFromSpec(array $value) + { + if (\is_array($value['tmp_name'])) { + return $this->normalizeNestedFileSpec($value); + } + + try { + $stream = $this->streamFactory->createStreamFromFile($value['tmp_name']); + } catch (\RuntimeException $e) { + $stream = $this->streamFactory->createStream(); + } + + return $this->uploadedFileFactory->createUploadedFile( + $stream, + (int) $value['size'], + (int) $value['error'], + $value['name'], + $value['type'] + ); + } + + /** + * Normalize an array of file specifications. + * + * Loops through all nested files and returns a normalized array of + * UploadedFileInterface instances. + * + * @param array $files + * + * @return UploadedFileInterface[] + */ + private function normalizeNestedFileSpec(array $files = []): array + { + $normalizedFiles = []; + + foreach (\array_keys($files['tmp_name']) as $key) { + $spec = [ + 'tmp_name' => $files['tmp_name'][$key], + 'size' => $files['size'][$key], + 'error' => $files['error'][$key], + 'name' => $files['name'][$key], + 'type' => $files['type'][$key], + ]; + $normalizedFiles[$key] = $this->createUploadedFileFromSpec($spec); + } + + return $normalizedFiles; + } + + /** + * Create a new uri from server variable. + * + * @param array $server typically $_SERVER or similar structure + */ + private function createUriFromArray(array $server): UriInterface + { + $uri = $this->uriFactory->createUri(''); + + if (isset($server['HTTP_X_FORWARDED_PROTO'])) { + $uri = $uri->withScheme($server['HTTP_X_FORWARDED_PROTO']); + } else { + if (isset($server['REQUEST_SCHEME'])) { + $uri = $uri->withScheme($server['REQUEST_SCHEME']); + } elseif (isset($server['HTTPS'])) { + $uri = $uri->withScheme('on' === $server['HTTPS'] ? 'https' : 'http'); + } + + if (isset($server['SERVER_PORT'])) { + $uri = $uri->withPort($server['SERVER_PORT']); + } + } + + if (isset($server['HTTP_HOST'])) { + if (1 === \preg_match('/^(.+)\:(\d+)$/', $server['HTTP_HOST'], $matches)) { + $uri = $uri->withHost($matches[1])->withPort($matches[2]); + } else { + $uri = $uri->withHost($server['HTTP_HOST']); + } + } elseif (isset($server['SERVER_NAME'])) { + $uri = $uri->withHost($server['SERVER_NAME']); + } + + if (isset($server['REQUEST_URI'])) { + $uri = $uri->withPath(\current(\explode('?', $server['REQUEST_URI']))); + } + + if (isset($server['QUERY_STRING'])) { + $uri = $uri->withQuery($server['QUERY_STRING']); + } + + return $uri; + } + } +} + +// file: vendor/nyholm/psr7-server/src/ServerRequestCreatorInterface.php +namespace Nyholm\Psr7Server { + + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Message\StreamInterface; + + /** + * @author Tobias Nyholm + * @author Martijn van der Ven + */ + interface ServerRequestCreatorInterface + { + /** + * Create a new server request from the current environment variables. + * Defaults to a GET request to minimise the risk of an \InvalidArgumentException. + * Includes the current request headers as supplied by the server through `getallheaders()`. + * If `getallheaders()` is unavailable on the current server it will fallback to its own `getHeadersFromServer()` method. + * Defaults to php://input for the request body. + * + * @throws \InvalidArgumentException if no valid method or URI can be determined + */ + public function fromGlobals(): ServerRequestInterface; + + /** + * Create a new server request from a set of arrays. + * + * @param array $server typically $_SERVER or similar structure + * @param array $headers typically the output of getallheaders() or similar structure + * @param array $cookie typically $_COOKIE or similar structure + * @param array $get typically $_GET or similar structure + * @param array $post typically $_POST or similar structure + * @param array $files typically $_FILES or similar structure + * @param StreamInterface|resource|string|null $body Typically stdIn + * + * @throws \InvalidArgumentException if no valid method or URI can be determined + */ + public function fromArrays( + array $server, + array $headers = [], + array $cookie = [], + array $get = [], + array $post = [], + array $files = [], + $body = null + ): ServerRequestInterface; + + /** + * Get parsed headers from ($_SERVER) array. + * + * @param array $server typically $_SERVER or similar structure + * + * @return array + */ + public static function getHeadersFromServer(array $server): array; + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/Cache.php +namespace Tqdev\PhpCrudApi\Cache { + + interface Cache + { + public function set(string $key, string $value, int $ttl = 0): bool; + public function get(string $key): string; + public function clear(): bool; + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/CacheFactory.php +namespace Tqdev\PhpCrudApi\Cache { + + class CacheFactory + { + public static function create(string $type, string $prefix, string $config): Cache + { + switch ($type) { + case 'TempFile': + $cache = new TempFileCache($prefix, $config); + break; + case 'Redis': + $cache = new RedisCache($prefix, $config); + break; + case 'Memcache': + $cache = new MemcacheCache($prefix, $config); + break; + case 'Memcached': + $cache = new MemcachedCache($prefix, $config); + break; + default: + $cache = new NoCache(); + } + return $cache; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/MemcacheCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class MemcacheCache implements Cache + { + protected $prefix; + protected $memcache; + + public function __construct(string $prefix, string $config) + { + $this->prefix = $prefix; + if ($config == '') { + $address = 'localhost'; + $port = 11211; + } elseif (strpos($config, ':') === false) { + $address = $config; + $port = 11211; + } else { + list($address, $port) = explode(':', $config); + } + $this->memcache = $this->create(); + $this->memcache->addServer($address, $port); + } + + protected function create() /*: \Memcache*/ + { + return new \Memcache(); + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return $this->memcache->set($this->prefix . $key, $value, 0, $ttl); + } + + public function get(string $key): string + { + return $this->memcache->get($this->prefix . $key) ?: ''; + } + + public function clear(): bool + { + return $this->memcache->flush(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/MemcachedCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class MemcachedCache extends MemcacheCache + { + protected function create() /*: \Memcached*/ + { + return new \Memcached(); + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return $this->memcache->set($this->prefix . $key, $value, $ttl); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/NoCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class NoCache implements Cache + { + public function __construct() + { + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return true; + } + + public function get(string $key): string + { + return ''; + } + + public function clear(): bool + { + return true; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/RedisCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class RedisCache implements Cache + { + protected $prefix; + protected $redis; + + public function __construct(string $prefix, string $config) + { + $this->prefix = $prefix; + if ($config == '') { + $config = '127.0.0.1'; + } + $params = explode(':', $config, 6); + if (isset($params[3])) { + $params[3] = null; + } + $this->redis = new \Redis(); + call_user_func_array(array($this->redis, 'pconnect'), $params); + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return $this->redis->set($this->prefix . $key, $value, $ttl); + } + + public function get(string $key): string + { + return $this->redis->get($this->prefix . $key) ?: ''; + } + + public function clear(): bool + { + return $this->redis->flushDb(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Cache/TempFileCache.php +namespace Tqdev\PhpCrudApi\Cache { + + class TempFileCache implements Cache + { + const SUFFIX = 'cache'; + + private $path; + private $segments; + + public function __construct(string $prefix, string $config) + { + $this->segments = []; + $s = DIRECTORY_SEPARATOR; + $ps = PATH_SEPARATOR; + if ($config == '') { + $this->path = sys_get_temp_dir() . $s . $prefix . self::SUFFIX; + } elseif (strpos($config, $ps) === false) { + $this->path = $config; + } else { + list($path, $segments) = explode($ps, $config); + $this->path = $path; + $this->segments = explode(',', $segments); + } + if (file_exists($this->path) && is_dir($this->path)) { + $this->clean($this->path, array_filter($this->segments), strlen(md5('')), false); + } + } + + private function getFileName(string $key): string + { + $s = DIRECTORY_SEPARATOR; + $md5 = md5($key); + $filename = rtrim($this->path, $s) . $s; + $i = 0; + foreach ($this->segments as $segment) { + $filename .= substr($md5, $i, $segment) . $s; + $i += $segment; + } + $filename .= substr($md5, $i); + return $filename; + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + $filename = $this->getFileName($key); + $dirname = dirname($filename); + if (!file_exists($dirname)) { + if (!mkdir($dirname, 0755, true)) { + return false; + } + } + $string = $ttl . '|' . $value; + return $this->filePutContents($filename, $string) !== false; + } + + private function filePutContents($filename, $string) + { + return file_put_contents($filename, $string, LOCK_EX); + } + + private function fileGetContents($filename) + { + $file = fopen($filename, 'rb'); + if ($file === false) { + return false; + } + $lock = flock($file, LOCK_SH); + if (!$lock) { + fclose($file); + return false; + } + $string = ''; + while (!feof($file)) { + $string .= fread($file, 8192); + } + flock($file, LOCK_UN); + fclose($file); + return $string; + } + + private function getString($filename): string + { + $data = $this->fileGetContents($filename); + if ($data === false) { + return ''; + } + if (strpos($data, '|') === false) { + return ''; + } + list($ttl, $string) = explode('|', $data, 2); + if ($ttl > 0 && time() - filemtime($filename) > $ttl) { + return ''; + } + return $string; + } + + public function get(string $key): string + { + $filename = $this->getFileName($key); + if (!file_exists($filename)) { + return ''; + } + $string = $this->getString($filename); + if ($string == null) { + return ''; + } + return $string; + } + + private function clean(string $path, array $segments, int $len, bool $all) /*: void*/ + { + $entries = scandir($path); + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $filename = $path . DIRECTORY_SEPARATOR . $entry; + if (count($segments) == 0) { + if (strlen($entry) != $len) { + continue; + } + if (file_exists($filename) && is_file($filename)) { + if ($all || $this->getString($filename) == null) { + @unlink($filename); + } + } + } else { + if (strlen($entry) != $segments[0]) { + continue; + } + if (file_exists($filename) && is_dir($filename)) { + $this->clean($filename, array_slice($segments, 1), $len - $segments[0], $all); + @rmdir($filename); + } + } + } + } + + public function clear(): bool + { + if (!file_exists($this->path) || !is_dir($this->path)) { + return false; + } + $this->clean($this->path, array_filter($this->segments), strlen(md5('')), true); + return true; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php +namespace Tqdev\PhpCrudApi\Column\Reflection { + + use Tqdev\PhpCrudApi\Database\GenericReflection; + + class ReflectedColumn implements \JsonSerializable + { + const DEFAULT_LENGTH = 255; + const DEFAULT_PRECISION = 19; + const DEFAULT_SCALE = 4; + + private $name; + private $type; + private $length; + private $precision; + private $scale; + private $nullable; + private $pk; + private $fk; + + public function __construct(string $name, string $type, int $length, int $precision, int $scale, bool $nullable, bool $pk, string $fk) + { + $this->name = $name; + $this->type = $type; + $this->length = $length; + $this->precision = $precision; + $this->scale = $scale; + $this->nullable = $nullable; + $this->pk = $pk; + $this->fk = $fk; + $this->sanitize(); + } + + private static function parseColumnType(string $columnType, int &$length, int &$precision, int &$scale) /*: void*/ + { + if (!$columnType) { + return; + } + $pos = strpos($columnType, '('); + if ($pos) { + $dataSize = rtrim(substr($columnType, $pos + 1), ')'); + if ($length) { + $length = (int) $dataSize; + } else { + $pos = strpos($dataSize, ','); + if ($pos) { + $precision = (int) substr($dataSize, 0, $pos); + $scale = (int) substr($dataSize, $pos + 1); + } else { + $precision = (int) $dataSize; + $scale = 0; + } + } + } + } + + private static function getDataSize(int $length, int $precision, int $scale): string + { + $dataSize = ''; + if ($length) { + $dataSize = $length; + } elseif ($precision) { + if ($scale) { + $dataSize = $precision . ',' . $scale; + } else { + $dataSize = $precision; + } + } + return $dataSize; + } + + public static function fromReflection(GenericReflection $reflection, array $columnResult): ReflectedColumn + { + $name = $columnResult['COLUMN_NAME']; + $dataType = $columnResult['DATA_TYPE']; + $length = (int) $columnResult['CHARACTER_MAXIMUM_LENGTH']; + $precision = (int) $columnResult['NUMERIC_PRECISION']; + $scale = (int) $columnResult['NUMERIC_SCALE']; + $columnType = $columnResult['COLUMN_TYPE']; + self::parseColumnType($columnType, $length, $precision, $scale); + $dataSize = self::getDataSize($length, $precision, $scale); + $type = $reflection->toJdbcType($dataType, $dataSize); + $nullable = in_array(strtoupper($columnResult['IS_NULLABLE']), ['TRUE', 'YES', 'T', 'Y', '1']); + $pk = false; + $fk = ''; + return new ReflectedColumn($name, $type, $length, $precision, $scale, $nullable, $pk, $fk); + } + + public static function fromJson(/* object */$json): ReflectedColumn + { + $name = $json->name; + $type = $json->type; + $length = isset($json->length) ? (int) $json->length : 0; + $precision = isset($json->precision) ? (int) $json->precision : 0; + $scale = isset($json->scale) ? (int) $json->scale : 0; + $nullable = isset($json->nullable) ? (bool) $json->nullable : false; + $pk = isset($json->pk) ? (bool) $json->pk : false; + $fk = isset($json->fk) ? $json->fk : ''; + return new ReflectedColumn($name, $type, $length, $precision, $scale, $nullable, $pk, $fk); + } + + private function sanitize() + { + $this->length = $this->hasLength() ? $this->getLength() : 0; + $this->precision = $this->hasPrecision() ? $this->getPrecision() : 0; + $this->scale = $this->hasScale() ? $this->getScale() : 0; + } + + public function getName(): string + { + return $this->name; + } + + public function getNullable(): bool + { + return $this->nullable; + } + + public function getType(): string + { + return $this->type; + } + + public function getLength(): int + { + return $this->length ?: self::DEFAULT_LENGTH; + } + + public function getPrecision(): int + { + return $this->precision ?: self::DEFAULT_PRECISION; + } + + public function getScale(): int + { + return $this->scale ?: self::DEFAULT_SCALE; + } + + public function hasLength(): bool + { + return in_array($this->type, ['varchar', 'varbinary']); + } + + public function hasPrecision(): bool + { + return $this->type == 'decimal'; + } + + public function hasScale(): bool + { + return $this->type == 'decimal'; + } + + public function isBinary(): bool + { + return in_array($this->type, ['blob', 'varbinary']); + } + + public function isBoolean(): bool + { + return $this->type == 'boolean'; + } + + public function isGeometry(): bool + { + return $this->type == 'geometry'; + } + + public function isInteger(): bool + { + return in_array($this->type, ['integer', 'bigint', 'smallint', 'tinyint']); + } + + public function setPk($value) /*: void*/ + { + $this->pk = $value; + } + + public function getPk(): bool + { + return $this->pk; + } + + public function setFk($value) /*: void*/ + { + $this->fk = $value; + } + + public function getFk(): string + { + return $this->fk; + } + + public function serialize() + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'length' => $this->length, + 'precision' => $this->precision, + 'scale' => $this->scale, + 'nullable' => $this->nullable, + 'pk' => $this->pk, + 'fk' => $this->fk, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedDatabase.php +namespace Tqdev\PhpCrudApi\Column\Reflection { + + use Tqdev\PhpCrudApi\Database\GenericReflection; + + class ReflectedDatabase implements \JsonSerializable + { + private $tableTypes; + + public function __construct(array $tableTypes) + { + $this->tableTypes = $tableTypes; + } + + public static function fromReflection(GenericReflection $reflection): ReflectedDatabase + { + $tableTypes = []; + foreach ($reflection->getTables() as $table) { + $tableName = $table['TABLE_NAME']; + $tableType = $table['TABLE_TYPE']; + if (in_array($tableName, $reflection->getIgnoredTables())) { + continue; + } + $tableTypes[$tableName] = $tableType; + } + return new ReflectedDatabase($tableTypes); + } + + public static function fromJson(/* object */$json): ReflectedDatabase + { + $tableTypes = (array) $json->tables; + return new ReflectedDatabase($tableTypes); + } + + public function hasTable(string $tableName): bool + { + return isset($this->tableTypes[$tableName]); + } + + public function getType(string $tableName): string + { + return isset($this->tableTypes[$tableName]) ? $this->tableTypes[$tableName] : ''; + } + + public function getTableNames(): array + { + return array_keys($this->tableTypes); + } + + public function removeTable(string $tableName): bool + { + if (!isset($this->tableTypes[$tableName])) { + return false; + } + unset($this->tableTypes[$tableName]); + return true; + } + + public function serialize() + { + return [ + 'tables' => $this->tableTypes, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedTable.php +namespace Tqdev\PhpCrudApi\Column\Reflection { + + use Tqdev\PhpCrudApi\Database\GenericReflection; + + class ReflectedTable implements \JsonSerializable + { + private $name; + private $type; + private $columns; + private $pk; + private $fks; + + public function __construct(string $name, string $type, array $columns) + { + $this->name = $name; + $this->type = $type; + // set columns + $this->columns = []; + foreach ($columns as $column) { + $columnName = $column->getName(); + $this->columns[$columnName] = $column; + } + // set primary key + $this->pk = null; + foreach ($columns as $column) { + if ($column->getPk() == true) { + $this->pk = $column; + } + } + // set foreign keys + $this->fks = []; + foreach ($columns as $column) { + $columnName = $column->getName(); + $referencedTableName = $column->getFk(); + if ($referencedTableName != '') { + $this->fks[$columnName] = $referencedTableName; + } + } + } + + public static function fromReflection(GenericReflection $reflection, string $name, string $type): ReflectedTable + { + // set columns + $columns = []; + foreach ($reflection->getTableColumns($name, $type) as $tableColumn) { + $column = ReflectedColumn::fromReflection($reflection, $tableColumn); + $columns[$column->getName()] = $column; + } + // set primary key + $columnName = false; + if ($type == 'view') { + $columnName = 'id'; + } else { + $columnNames = $reflection->getTablePrimaryKeys($name); + if (count($columnNames) == 1) { + $columnName = $columnNames[0]; + } + } + if ($columnName && isset($columns[$columnName])) { + $pk = $columns[$columnName]; + $pk->setPk(true); + } + // set foreign keys + if ($type == 'view') { + $tables = $reflection->getTables(); + foreach ($columns as $columnName => $column) { + if (substr($columnName, -3) == '_id') { + foreach ($tables as $table) { + $tableName = $table['TABLE_NAME']; + $suffix = $tableName . '_id'; + if (substr($columnName, -1 * strlen($suffix)) == $suffix) { + $column->setFk($tableName); + } + } + } + } + } else { + $fks = $reflection->getTableForeignKeys($name); + foreach ($fks as $columnName => $table) { + $columns[$columnName]->setFk($table); + } + } + return new ReflectedTable($name, $type, array_values($columns)); + } + + public static function fromJson( /* object */$json): ReflectedTable + { + $name = $json->name; + $type = isset($json->type) ? $json->type : 'table'; + $columns = []; + if (isset($json->columns) && is_array($json->columns)) { + foreach ($json->columns as $column) { + $columns[] = ReflectedColumn::fromJson($column); + } + } + return new ReflectedTable($name, $type, $columns); + } + + public function hasColumn(string $columnName): bool + { + return isset($this->columns[$columnName]); + } + + public function hasPk(): bool + { + return $this->pk != null; + } + + public function getPk() /*: ?ReflectedColumn */ + { + return $this->pk; + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): string + { + return $this->type; + } + + public function getColumnNames(): array + { + return array_keys($this->columns); + } + + public function getColumn($columnName): ReflectedColumn + { + return $this->columns[$columnName]; + } + + public function getFksTo(string $tableName): array + { + $columns = array(); + foreach ($this->fks as $columnName => $referencedTableName) { + if ($tableName == $referencedTableName && !is_null($this->columns[$columnName])) { + $columns[] = $this->columns[$columnName]; + } + } + return $columns; + } + + public function removeColumn(string $columnName): bool + { + if (!isset($this->columns[$columnName])) { + return false; + } + unset($this->columns[$columnName]); + return true; + } + + public function serialize() + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'columns' => array_values($this->columns), + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/DefinitionService.php +namespace Tqdev\PhpCrudApi\Column { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Database\GenericDB; + + class DefinitionService + { + private $db; + private $reflection; + + public function __construct(GenericDB $db, ReflectionService $reflection) + { + $this->db = $db; + $this->reflection = $reflection; + } + + public function updateTable(string $tableName, /* object */ $changes): bool + { + $table = $this->reflection->getTable($tableName); + $newTable = ReflectedTable::fromJson((object) array_merge((array) $table->jsonSerialize(), (array) $changes)); + if ($table->getName() != $newTable->getName()) { + if (!$this->db->definition()->renameTable($table->getName(), $newTable->getName())) { + return false; + } + } + return true; + } + + public function updateColumn(string $tableName, string $columnName, /* object */ $changes): bool + { + $table = $this->reflection->getTable($tableName); + $column = $table->getColumn($columnName); + + // remove constraints on other column + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + if ($newColumn->getPk() != $column->getPk() && $table->hasPk()) { + $oldColumn = $table->getPk(); + if ($oldColumn->getName() != $columnName) { + $oldColumn->setPk(false); + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $oldColumn->getName(), $oldColumn)) { + return false; + } + } + } + + // remove constraints + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), ['pk' => false, 'fk' => false])); + if ($newColumn->getPk() != $column->getPk() && !$newColumn->getPk()) { + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getFk() != $column->getFk() && !$newColumn->getFk()) { + if (!$this->db->definition()->removeColumnForeignKey($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + + // name and type + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + $newColumn->setPk(false); + $newColumn->setFk(''); + if ($newColumn->getName() != $column->getName()) { + if (!$this->db->definition()->renameColumn($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + if ( + $newColumn->getType() != $column->getType() || + $newColumn->getLength() != $column->getLength() || + $newColumn->getPrecision() != $column->getPrecision() || + $newColumn->getScale() != $column->getScale() + ) { + if (!$this->db->definition()->retypeColumn($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getNullable() != $column->getNullable()) { + if (!$this->db->definition()->setColumnNullable($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + + // add constraints + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + if ($newColumn->getFk()) { + if (!$this->db->definition()->addColumnForeignKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getPk()) { + if (!$this->db->definition()->addColumnPrimaryKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + return true; + } + + public function addTable(/* object */$definition) + { + $newTable = ReflectedTable::fromJson($definition); + if (!$this->db->definition()->addTable($newTable)) { + return false; + } + return true; + } + + public function addColumn(string $tableName, /* object */ $definition) + { + $newColumn = ReflectedColumn::fromJson($definition); + if (!$this->db->definition()->addColumn($tableName, $newColumn)) { + return false; + } + if ($newColumn->getFk()) { + if (!$this->db->definition()->addColumnForeignKey($tableName, $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getPk()) { + if (!$this->db->definition()->addColumnPrimaryKey($tableName, $newColumn->getName(), $newColumn)) { + return false; + } + } + return true; + } + + public function removeTable(string $tableName) + { + if (!$this->db->definition()->removeTable($tableName)) { + return false; + } + return true; + } + + public function removeColumn(string $tableName, string $columnName) + { + $table = $this->reflection->getTable($tableName); + $newColumn = $table->getColumn($columnName); + if ($newColumn->getPk()) { + $newColumn->setPk(false); + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getFk()) { + $newColumn->setFk(""); + if (!$this->db->definition()->removeColumnForeignKey($tableName, $columnName, $newColumn)) { + return false; + } + } + if (!$this->db->definition()->removeColumn($tableName, $columnName)) { + return false; + } + return true; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Column/ReflectionService.php +namespace Tqdev\PhpCrudApi\Column { + + use Tqdev\PhpCrudApi\Cache\Cache; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedDatabase; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Database\GenericDB; + + class ReflectionService + { + private $db; + private $cache; + private $ttl; + private $database; + private $tables; + + public function __construct(GenericDB $db, Cache $cache, int $ttl) + { + $this->db = $db; + $this->cache = $cache; + $this->ttl = $ttl; + $this->database = null; + $this->tables = []; + } + + private function database(): ReflectedDatabase + { + if ($this->database) { + return $this->database; + } + $this->database = $this->loadDatabase(true); + return $this->database; + } + + private function loadDatabase(bool $useCache): ReflectedDatabase + { + $key = sprintf('%s-ReflectedDatabase', $this->db->getCacheKey()); + $data = $useCache ? $this->cache->get($key) : ''; + if ($data != '') { + $database = ReflectedDatabase::fromJson(json_decode(gzuncompress($data))); + } else { + $database = ReflectedDatabase::fromReflection($this->db->reflection()); + $data = gzcompress(json_encode($database, JSON_UNESCAPED_UNICODE)); + $this->cache->set($key, $data, $this->ttl); + } + return $database; + } + + private function loadTable(string $tableName, bool $useCache): ReflectedTable + { + $key = sprintf('%s-ReflectedTable(%s)', $this->db->getCacheKey(), $tableName); + $data = $useCache ? $this->cache->get($key) : ''; + if ($data != '') { + $table = ReflectedTable::fromJson(json_decode(gzuncompress($data))); + } else { + $tableType = $this->database()->getType($tableName); + $table = ReflectedTable::fromReflection($this->db->reflection(), $tableName, $tableType); + $data = gzcompress(json_encode($table, JSON_UNESCAPED_UNICODE)); + $this->cache->set($key, $data, $this->ttl); + } + return $table; + } + + public function refreshTables() + { + $this->database = $this->loadDatabase(false); + } + + public function refreshTable(string $tableName) + { + $this->tables[$tableName] = $this->loadTable($tableName, false); + } + + public function hasTable(string $tableName): bool + { + return $this->database()->hasTable($tableName); + } + + public function getType(string $tableName): string + { + return $this->database()->getType($tableName); + } + + public function getTable(string $tableName): ReflectedTable + { + if (!isset($this->tables[$tableName])) { + $this->tables[$tableName] = $this->loadTable($tableName, true); + } + return $this->tables[$tableName]; + } + + public function getTableNames(): array + { + return $this->database()->getTableNames(); + } + + public function removeTable(string $tableName): bool + { + unset($this->tables[$tableName]); + return $this->database()->removeTable($tableName); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/CacheController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Cache\Cache; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + + class CacheController + { + private $cache; + private $responder; + + public function __construct(Router $router, Responder $responder, Cache $cache) + { + $router->register('GET', '/cache/clear', array($this, 'clear')); + $this->cache = $cache; + $this->responder = $responder; + } + + public function clear(ServerRequestInterface $request): ResponseInterface + { + return $this->responder->success($this->cache->clear()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/ColumnController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Column\DefinitionService; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class ColumnController + { + private $responder; + private $reflection; + private $definition; + + public function __construct(Router $router, Responder $responder, ReflectionService $reflection, DefinitionService $definition) + { + $router->register('GET', '/columns', array($this, 'getDatabase')); + $router->register('GET', '/columns/*', array($this, 'getTable')); + $router->register('GET', '/columns/*/*', array($this, 'getColumn')); + $router->register('PUT', '/columns/*', array($this, 'updateTable')); + $router->register('PUT', '/columns/*/*', array($this, 'updateColumn')); + $router->register('POST', '/columns', array($this, 'addTable')); + $router->register('POST', '/columns/*', array($this, 'addColumn')); + $router->register('DELETE', '/columns/*', array($this, 'removeTable')); + $router->register('DELETE', '/columns/*/*', array($this, 'removeColumn')); + $this->responder = $responder; + $this->reflection = $reflection; + $this->definition = $definition; + } + + public function getDatabase(ServerRequestInterface $request): ResponseInterface + { + $tables = []; + foreach ($this->reflection->getTableNames() as $table) { + $tables[] = $this->reflection->getTable($table); + } + $database = ['tables' => $tables]; + return $this->responder->success($database); + } + + public function getTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + return $this->responder->success($table); + } + + public function getColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $column = $table->getColumn($columnName); + return $this->responder->success($column); + } + + public function updateTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $success = $this->definition->updateTable($tableName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function updateColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $success = $this->definition->updateColumn($tableName, $columnName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } + + public function addTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = $request->getParsedBody()->name; + if ($this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_ALREADY_EXISTS, $tableName); + } + $success = $this->definition->addTable($request->getParsedBody()); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function addColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $columnName = $request->getParsedBody()->name; + $table = $this->reflection->getTable($tableName); + if ($table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_ALREADY_EXISTS, $columnName); + } + $success = $this->definition->addColumn($tableName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } + + public function removeTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $success = $this->definition->removeTable($tableName); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function removeColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $success = $this->definition->removeColumn($tableName, $columnName); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/GeoJsonController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\GeoJson\GeoJsonService; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class GeoJsonController + { + private $service; + private $responder; + + public function __construct(Router $router, Responder $responder, GeoJsonService $service) + { + $router->register('GET', '/geojson/*', array($this, '_list')); + $router->register('GET', '/geojson/*/*', array($this, 'read')); + $this->service = $service; + $this->responder = $responder; + } + + public function _list(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + $params = RequestUtils::getParams($request); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + return $this->responder->success($this->service->_list($table, $params)); + } + + public function read(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + if (strpos($id, ',') !== false) { + $ids = explode(',', $id); + $result = (object) array('type' => 'FeatureCollection', 'features' => array()); + for ($i = 0; $i < count($ids); $i++) { + array_push($result->features, $this->service->read($table, $ids[$i], $params)); + } + return $this->responder->success($result); + } else { + $response = $this->service->read($table, $id, $params); + if ($response === null) { + return $this->responder->error(ErrorCode::RECORD_NOT_FOUND, $id); + } + return $this->responder->success($response); + } + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/JsonResponder.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Tqdev\PhpCrudApi\Record\Document\ErrorDocument; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\ResponseFactory; + + class JsonResponder implements Responder + { + public function error(int $error, string $argument, $details = null): ResponseInterface + { + $errorCode = new ErrorCode($error); + $status = $errorCode->getStatus(); + $document = new ErrorDocument($errorCode, $argument, $details); + return ResponseFactory::fromObject($status, $document); + } + + public function success($result): ResponseInterface + { + return ResponseFactory::fromObject(ResponseFactory::OK, $result); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/OpenApiController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\OpenApi\OpenApiService; + + class OpenApiController + { + private $openApi; + private $responder; + + public function __construct(Router $router, Responder $responder, OpenApiService $openApi) + { + $router->register('GET', '/openapi', array($this, 'openapi')); + $this->openApi = $openApi; + $this->responder = $responder; + } + + public function openapi(ServerRequestInterface $request): ResponseInterface + { + return $this->responder->success($this->openApi->get()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/RecordController.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\RecordService; + use Tqdev\PhpCrudApi\RequestUtils; + + class RecordController + { + private $service; + private $responder; + + public function __construct(Router $router, Responder $responder, RecordService $service) + { + $router->register('GET', '/records/*', array($this, '_list')); + $router->register('POST', '/records/*', array($this, 'create')); + $router->register('GET', '/records/*/*', array($this, 'read')); + $router->register('PUT', '/records/*/*', array($this, 'update')); + $router->register('DELETE', '/records/*/*', array($this, 'delete')); + $router->register('PATCH', '/records/*/*', array($this, 'increment')); + $this->service = $service; + $this->responder = $responder; + } + + public function _list(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + $params = RequestUtils::getParams($request); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + return $this->responder->success($this->service->_list($table, $params)); + } + + public function read(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + if (strpos($id, ',') !== false) { + $ids = explode(',', $id); + $result = []; + for ($i = 0; $i < count($ids); $i++) { + array_push($result, $this->service->read($table, $ids[$i], $params)); + } + return $this->responder->success($result); + } else { + $response = $this->service->read($table, $id, $params); + if ($response === null) { + return $this->responder->error(ErrorCode::RECORD_NOT_FOUND, $id); + } + return $this->responder->success($response); + } + } + + public function create(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $params = RequestUtils::getParams($request); + if (is_array($record)) { + $result = array(); + foreach ($record as $r) { + $result[] = $this->service->create($table, $r, $params); + } + return $this->responder->success($result); + } else { + return $this->responder->success($this->service->create($table, $record, $params)); + } + } + + public function update(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $ids = explode(',', $id); + if (is_array($record)) { + if (count($ids) != count($record)) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->update($table, $ids[$i], $record[$i], $params); + } + return $this->responder->success($result); + } else { + if (count($ids) != 1) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + return $this->responder->success($this->service->update($table, $id, $record, $params)); + } + } + + public function delete(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + $ids = explode(',', $id); + if (count($ids) > 1) { + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->delete($table, $ids[$i], $params); + } + return $this->responder->success($result); + } else { + return $this->responder->success($this->service->delete($table, $id, $params)); + } + } + + public function increment(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $params = RequestUtils::getParams($request); + $ids = explode(',', $id); + if (is_array($record)) { + if (count($ids) != count($record)) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->increment($table, $ids[$i], $record[$i], $params); + } + return $this->responder->success($result); + } else { + if (count($ids) != 1) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + return $this->responder->success($this->service->increment($table, $id, $record, $params)); + } + } + } +} + +// file: src/Tqdev/PhpCrudApi/Controller/Responder.php +namespace Tqdev\PhpCrudApi\Controller { + + use Psr\Http\Message\ResponseInterface; + + interface Responder + { + public function error(int $error, string $argument, $details = null): ResponseInterface; + + public function success($result): ResponseInterface; + } +} + +// file: src/Tqdev/PhpCrudApi/Database/ColumnConverter.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + + class ColumnConverter + { + private $driver; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + public function convertColumnValue(ReflectedColumn $column): string + { + if ($column->isBoolean()) { + switch ($this->driver) { + case 'mysql': + return "IFNULL(IF(?,TRUE,FALSE),NULL)"; + case 'pgsql': + return "?"; + case 'sqlsrv': + return "?"; + } + } + if ($column->isBinary()) { + switch ($this->driver) { + case 'mysql': + return "FROM_BASE64(?)"; + case 'pgsql': + return "decode(?, 'base64')"; + case 'sqlsrv': + return "CONVERT(XML, ?).value('.','varbinary(max)')"; + } + } + if ($column->isGeometry()) { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ST_GeomFromText(?)"; + case 'sqlsrv': + return "geometry::STGeomFromText(?,0)"; + } + } + return '?'; + } + + public function convertColumnName(ReflectedColumn $column, $value): string + { + if ($column->isBinary()) { + switch ($this->driver) { + case 'mysql': + return "TO_BASE64($value) as $value"; + case 'pgsql': + return "encode($value::bytea, 'base64') as $value"; + case 'sqlsrv': + return "CASE WHEN $value IS NULL THEN NULL ELSE (SELECT CAST($value as varbinary(max)) FOR XML PATH(''), BINARY BASE64) END as $value"; + } + } + if ($column->isGeometry()) { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ST_AsText($value) as $value"; + case 'sqlsrv': + return "REPLACE($value.STAsText(),' (','(') as $value"; + } + } + return $value; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/ColumnsBuilder.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + class ColumnsBuilder + { + private $driver; + private $converter; + + public function __construct(string $driver) + { + $this->driver = $driver; + $this->converter = new ColumnConverter($driver); + } + + public function getOffsetLimit(int $offset, int $limit): string + { + if ($limit < 0 || $offset < 0) { + return ''; + } + switch ($this->driver) { + case 'mysql': + return " LIMIT $offset, $limit"; + case 'pgsql': + return " LIMIT $limit OFFSET $offset"; + case 'sqlsrv': + return " OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY"; + case 'sqlite': + return " LIMIT $limit OFFSET $offset"; + } + } + + private function quoteColumnName(ReflectedColumn $column): string + { + return '"' . $column->getName() . '"'; + } + + public function getOrderBy(ReflectedTable $table, array $columnOrdering): string + { + if (count($columnOrdering) == 0) { + return ''; + } + $results = array(); + foreach ($columnOrdering as $i => list($columnName, $ordering)) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $results[] = $quotedColumnName . ' ' . $ordering; + } + return ' ORDER BY ' . implode(',', $results); + } + + public function getSelect(ReflectedTable $table, array $columnNames): string + { + $results = array(); + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $quotedColumnName = $this->converter->convertColumnName($column, $quotedColumnName); + $results[] = $quotedColumnName; + } + return implode(',', $results); + } + + public function getInsert(ReflectedTable $table, array $columnValues): string + { + $columns = array(); + $values = array(); + foreach ($columnValues as $columnName => $columnValue) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columns[] = $quotedColumnName; + $columnValue = $this->converter->convertColumnValue($column); + $values[] = $columnValue; + } + $columnsSql = '(' . implode(',', $columns) . ')'; + $valuesSql = '(' . implode(',', $values) . ')'; + $outputColumn = $this->quoteColumnName($table->getPk()); + switch ($this->driver) { + case 'mysql': + return "$columnsSql VALUES $valuesSql"; + case 'pgsql': + return "$columnsSql VALUES $valuesSql RETURNING $outputColumn"; + case 'sqlsrv': + return "$columnsSql OUTPUT INSERTED.$outputColumn VALUES $valuesSql"; + case 'sqlite': + return "$columnsSql VALUES $valuesSql"; + } + } + + public function getUpdate(ReflectedTable $table, array $columnValues): string + { + $results = array(); + foreach ($columnValues as $columnName => $columnValue) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columnValue = $this->converter->convertColumnValue($column); + $results[] = $quotedColumnName . '=' . $columnValue; + } + return implode(',', $results); + } + + public function getIncrement(ReflectedTable $table, array $columnValues): string + { + $results = array(); + foreach ($columnValues as $columnName => $columnValue) { + if (!is_numeric($columnValue)) { + continue; + } + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columnValue = $this->converter->convertColumnValue($column); + $results[] = $quotedColumnName . '=' . $quotedColumnName . '+' . $columnValue; + } + return implode(',', $results); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/ConditionsBuilder.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Record\Condition\AndCondition; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\Condition\Condition; + use Tqdev\PhpCrudApi\Record\Condition\NoCondition; + use Tqdev\PhpCrudApi\Record\Condition\NotCondition; + use Tqdev\PhpCrudApi\Record\Condition\OrCondition; + use Tqdev\PhpCrudApi\Record\Condition\SpatialCondition; + + class ConditionsBuilder + { + private $driver; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + private function getConditionSql(Condition $condition, array &$arguments): string + { + if ($condition instanceof AndCondition) { + return $this->getAndConditionSql($condition, $arguments); + } + if ($condition instanceof OrCondition) { + return $this->getOrConditionSql($condition, $arguments); + } + if ($condition instanceof NotCondition) { + return $this->getNotConditionSql($condition, $arguments); + } + if ($condition instanceof SpatialCondition) { + return $this->getSpatialConditionSql($condition, $arguments); + } + if ($condition instanceof ColumnCondition) { + return $this->getColumnConditionSql($condition, $arguments); + } + throw new \Exception('Unknown Condition: ' . get_class($condition)); + } + + private function getAndConditionSql(AndCondition $and, array &$arguments): string + { + $parts = []; + foreach ($and->getConditions() as $condition) { + $parts[] = $this->getConditionSql($condition, $arguments); + } + return '(' . implode(' AND ', $parts) . ')'; + } + + private function getOrConditionSql(OrCondition $or, array &$arguments): string + { + $parts = []; + foreach ($or->getConditions() as $condition) { + $parts[] = $this->getConditionSql($condition, $arguments); + } + return '(' . implode(' OR ', $parts) . ')'; + } + + private function getNotConditionSql(NotCondition $not, array &$arguments): string + { + $condition = $not->getCondition(); + return '(NOT ' . $this->getConditionSql($condition, $arguments) . ')'; + } + + private function quoteColumnName(ReflectedColumn $column): string + { + return '"' . $column->getName() . '"'; + } + + private function escapeLikeValue(string $value): string + { + return addcslashes($value, '%_'); + } + + private function getColumnConditionSql(ColumnCondition $condition, array &$arguments): string + { + $column = $this->quoteColumnName($condition->getColumn()); + $operator = $condition->getOperator(); + $value = $condition->getValue(); + switch ($operator) { + case 'cs': + $sql = "$column LIKE ?"; + $arguments[] = '%' . $this->escapeLikeValue($value) . '%'; + break; + case 'sw': + $sql = "$column LIKE ?"; + $arguments[] = $this->escapeLikeValue($value) . '%'; + break; + case 'ew': + $sql = "$column LIKE ?"; + $arguments[] = '%' . $this->escapeLikeValue($value); + break; + case 'eq': + $sql = "$column = ?"; + $arguments[] = $value; + break; + case 'lt': + $sql = "$column < ?"; + $arguments[] = $value; + break; + case 'le': + $sql = "$column <= ?"; + $arguments[] = $value; + break; + case 'ge': + $sql = "$column >= ?"; + $arguments[] = $value; + break; + case 'gt': + $sql = "$column > ?"; + $arguments[] = $value; + break; + case 'bt': + $parts = explode(',', $value, 2); + $count = count($parts); + if ($count == 2) { + $sql = "($column >= ? AND $column <= ?)"; + $arguments[] = $parts[0]; + $arguments[] = $parts[1]; + } else { + $sql = "FALSE"; + } + break; + case 'in': + $parts = explode(',', $value); + $count = count($parts); + if ($count > 0) { + $qmarks = implode(',', str_split(str_repeat('?', $count))); + $sql = "$column IN ($qmarks)"; + for ($i = 0; $i < $count; $i++) { + $arguments[] = $parts[$i]; + } + } else { + $sql = "FALSE"; + } + break; + case 'is': + $sql = "$column IS NULL"; + break; + } + return $sql; + } + + private function getSpatialFunctionName(string $operator): string + { + switch ($operator) { + case 'co': + return 'ST_Contains'; + case 'cr': + return 'ST_Crosses'; + case 'di': + return 'ST_Disjoint'; + case 'eq': + return 'ST_Equals'; + case 'in': + return 'ST_Intersects'; + case 'ov': + return 'ST_Overlaps'; + case 'to': + return 'ST_Touches'; + case 'wi': + return 'ST_Within'; + case 'ic': + return 'ST_IsClosed'; + case 'is': + return 'ST_IsSimple'; + case 'iv': + return 'ST_IsValid'; + } + } + + private function hasSpatialArgument(string $operator): bool + { + return in_array($operator, ['ic', 'is', 'iv']) ? false : true; + } + + private function getSpatialFunctionCall(string $functionName, string $column, bool $hasArgument): string + { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + $argument = $hasArgument ? 'ST_GeomFromText(?)' : ''; + return "$functionName($column, $argument)=TRUE"; + case 'sqlsrv': + $functionName = str_replace('ST_', 'ST', $functionName); + $argument = $hasArgument ? 'geometry::STGeomFromText(?,0)' : ''; + return "$column.$functionName($argument)=1"; + case 'sqlite': + $argument = $hasArgument ? '?' : '0'; + return "$functionName($column, $argument)=1"; + } + } + + private function getSpatialConditionSql(ColumnCondition $condition, array &$arguments): string + { + $column = $this->quoteColumnName($condition->getColumn()); + $operator = $condition->getOperator(); + $value = $condition->getValue(); + $functionName = $this->getSpatialFunctionName($operator); + $hasArgument = $this->hasSpatialArgument($operator); + $sql = $this->getSpatialFunctionCall($functionName, $column, $hasArgument); + if ($hasArgument) { + $arguments[] = $value; + } + return $sql; + } + + public function getWhereClause(Condition $condition, array &$arguments): string + { + if ($condition instanceof NoCondition) { + return ''; + } + return ' WHERE ' . $this->getConditionSql($condition, $arguments); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/DataConverter.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + class DataConverter + { + private $driver; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + private function convertRecordValue($conversion, $value) + { + $args = explode('|', $conversion); + $type = array_shift($args); + switch ($type) { + case 'boolean': + return $value ? true : false; + case 'integer': + return (int) $value; + case 'float': + return (float) $value; + case 'decimal': + return number_format($value, $args[0], '.', ''); + } + return $value; + } + + private function getRecordValueConversion(ReflectedColumn $column): string + { + if (in_array($this->driver, ['mysql', 'sqlsrv', 'sqlite']) && $column->isBoolean()) { + return 'boolean'; + } + if (in_array($this->driver, ['sqlsrv', 'sqlite']) && in_array($column->getType(), ['integer', 'bigint'])) { + return 'integer'; + } + if (in_array($this->driver, ['sqlite', 'pgsql']) && in_array($column->getType(), ['float', 'double'])) { + return 'float'; + } + if (in_array($this->driver, ['sqlite']) && in_array($column->getType(), ['decimal'])) { + return 'decimal|' . $column->getScale(); + } + return 'none'; + } + + public function convertRecords(ReflectedTable $table, array $columnNames, array &$records) /*: void*/ + { + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $conversion = $this->getRecordValueConversion($column); + if ($conversion != 'none') { + foreach ($records as $i => $record) { + $value = $records[$i][$columnName]; + if ($value === null) { + continue; + } + $records[$i][$columnName] = $this->convertRecordValue($conversion, $value); + } + } + } + } + + private function convertInputValue($conversion, $value) + { + switch ($conversion) { + case 'boolean': + return $value ? 1 : 0; + case 'base64url_to_base64': + return str_pad(strtr($value, '-_', '+/'), ceil(strlen($value) / 4) * 4, '=', STR_PAD_RIGHT); + } + return $value; + } + + private function getInputValueConversion(ReflectedColumn $column): string + { + if ($column->isBoolean()) { + return 'boolean'; + } + if ($column->isBinary()) { + return 'base64url_to_base64'; + } + return 'none'; + } + + public function convertColumnValues(ReflectedTable $table, array &$columnValues) /*: void*/ + { + $columnNames = array_keys($columnValues); + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $conversion = $this->getInputValueConversion($column); + if ($conversion != 'none') { + $value = $columnValues[$columnName]; + if ($value !== null) { + $columnValues[$columnName] = $this->convertInputValue($conversion, $value); + } + } + } + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/GenericDB.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\Condition\Condition; + + class GenericDB + { + private $driver; + private $address; + private $port; + private $database; + private $tables; + private $username; + private $password; + private $pdo; + private $reflection; + private $definition; + private $conditions; + private $columns; + private $converter; + + private function getDsn(): string + { + switch ($this->driver) { + case 'mysql': + return "$this->driver:host=$this->address;port=$this->port;dbname=$this->database;charset=utf8mb4"; + case 'pgsql': + return "$this->driver:host=$this->address port=$this->port dbname=$this->database options='--client_encoding=UTF8'"; + case 'sqlsrv': + return "$this->driver:Server=$this->address,$this->port;Database=$this->database"; + case 'sqlite': + return "$this->driver:$this->address"; + } + } + + private function getCommands(): array + { + switch ($this->driver) { + case 'mysql': + return [ + 'SET SESSION sql_warnings=1;', + 'SET NAMES utf8mb4;', + 'SET SESSION sql_mode = "ANSI,TRADITIONAL";', + ]; + case 'pgsql': + return [ + "SET NAMES 'UTF8';", + ]; + case 'sqlsrv': + return []; + case 'sqlite': + return [ + 'PRAGMA foreign_keys = on;', + ]; + } + } + + private function getOptions(): array + { + $options = array( + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ); + switch ($this->driver) { + case 'mysql': + return $options + [ + \PDO::ATTR_EMULATE_PREPARES => false, + \PDO::MYSQL_ATTR_FOUND_ROWS => true, + \PDO::ATTR_PERSISTENT => true, + ]; + case 'pgsql': + return $options + [ + \PDO::ATTR_EMULATE_PREPARES => false, + \PDO::ATTR_PERSISTENT => true, + ]; + case 'sqlsrv': + return $options + [ + \PDO::SQLSRV_ATTR_DIRECT_QUERY => false, + \PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => true, + ]; + case 'sqlite': + return $options + []; + } + } + + private function initPdo(): bool + { + if ($this->pdo) { + $result = $this->pdo->reconstruct($this->getDsn(), $this->username, $this->password, $this->getOptions()); + } else { + $this->pdo = new LazyPdo($this->getDsn(), $this->username, $this->password, $this->getOptions()); + $result = true; + } + $commands = $this->getCommands(); + foreach ($commands as $command) { + $this->pdo->addInitCommand($command); + } + $this->reflection = new GenericReflection($this->pdo, $this->driver, $this->database, $this->tables); + $this->definition = new GenericDefinition($this->pdo, $this->driver, $this->database, $this->tables); + $this->conditions = new ConditionsBuilder($this->driver); + $this->columns = new ColumnsBuilder($this->driver); + $this->converter = new DataConverter($this->driver); + return $result; + } + + public function __construct(string $driver, string $address, int $port, string $database, array $tables, string $username, string $password) + { + $this->driver = $driver; + $this->address = $address; + $this->port = $port; + $this->database = $database; + $this->tables = $tables; + $this->username = $username; + $this->password = $password; + $this->initPdo(); + } + + public function reconstruct(string $driver, string $address, int $port, string $database, array $tables, string $username, string $password): bool + { + if ($driver) { + $this->driver = $driver; + } + if ($address) { + $this->address = $address; + } + if ($port) { + $this->port = $port; + } + if ($database) { + $this->database = $database; + } + if ($tables) { + $this->tables = $tables; + } + if ($username) { + $this->username = $username; + } + if ($password) { + $this->password = $password; + } + return $this->initPdo(); + } + + public function pdo(): LazyPdo + { + return $this->pdo; + } + + public function reflection(): GenericReflection + { + return $this->reflection; + } + + public function definition(): GenericDefinition + { + return $this->definition; + } + + private function addMiddlewareConditions(string $tableName, Condition $condition): Condition + { + $condition1 = VariableStore::get("authorization.conditions.$tableName"); + if ($condition1) { + $condition = $condition->_and($condition1); + } + $condition2 = VariableStore::get("multiTenancy.conditions.$tableName"); + if ($condition2) { + $condition = $condition->_and($condition2); + } + return $condition; + } + + public function createSingle(ReflectedTable $table, array $columnValues) /*: ?String*/ + { + $this->converter->convertColumnValues($table, $columnValues); + $insertColumns = $this->columns->getInsert($table, $columnValues); + $tableName = $table->getName(); + $pkName = $table->getPk()->getName(); + $parameters = array_values($columnValues); + $sql = 'INSERT INTO "' . $tableName . '" ' . $insertColumns; + $stmt = $this->query($sql, $parameters); + // return primary key value if specified in the input + if (isset($columnValues[$pkName])) { + return $columnValues[$pkName]; + } + // work around missing "returning" or "output" in mysql + switch ($this->driver) { + case 'mysql': + $stmt = $this->query('SELECT LAST_INSERT_ID()', []); + break; + case 'sqlite': + $stmt = $this->query('SELECT LAST_INSERT_ROWID()', []); + break; + } + $pkValue = $stmt->fetchColumn(0); + if ($this->driver == 'sqlsrv' && $table->getPk()->getType() == 'bigint') { + return (int) $pkValue; + } + if ($this->driver == 'sqlite' && in_array($table->getPk()->getType(), ['integer', 'bigint'])) { + return (int) $pkValue; + } + return $pkValue; + } + + public function selectSingle(ReflectedTable $table, array $columnNames, string $id) /*: ?array*/ + { + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + $record = $stmt->fetch() ?: null; + if ($record === null) { + return null; + } + $records = array($record); + $this->converter->convertRecords($table, $columnNames, $records); + return $records[0]; + } + + public function selectMultiple(ReflectedTable $table, array $columnNames, array $ids): array + { + if (count($ids) == 0) { + return []; + } + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'in', implode(',', $ids)); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + $records = $stmt->fetchAll(); + $this->converter->convertRecords($table, $columnNames, $records); + return $records; + } + + public function selectCount(ReflectedTable $table, Condition $condition): int + { + $tableName = $table->getName(); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT COUNT(*) FROM "' . $tableName . '"' . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->fetchColumn(0); + } + + public function selectAll(ReflectedTable $table, array $columnNames, Condition $condition, array $columnOrdering, int $offset, int $limit): array + { + if ($limit == 0) { + return array(); + } + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $orderBy = $this->columns->getOrderBy($table, $columnOrdering); + $offsetLimit = $this->columns->getOffsetLimit($offset, $limit); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '"' . $whereClause . $orderBy . $offsetLimit; + $stmt = $this->query($sql, $parameters); + $records = $stmt->fetchAll(); + $this->converter->convertRecords($table, $columnNames, $records); + return $records; + } + + public function updateSingle(ReflectedTable $table, array $columnValues, string $id) + { + if (count($columnValues) == 0) { + return 0; + } + $this->converter->convertColumnValues($table, $columnValues); + $updateColumns = $this->columns->getUpdate($table, $columnValues); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array_values($columnValues); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'UPDATE "' . $tableName . '" SET ' . $updateColumns . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + public function deleteSingle(ReflectedTable $table, string $id) + { + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'DELETE FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + public function incrementSingle(ReflectedTable $table, array $columnValues, string $id) + { + if (count($columnValues) == 0) { + return 0; + } + $this->converter->convertColumnValues($table, $columnValues); + $updateColumns = $this->columns->getIncrement($table, $columnValues); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array_values($columnValues); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'UPDATE "' . $tableName . '" SET ' . $updateColumns . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + private function query(string $sql, array $parameters): \PDOStatement + { + $stmt = $this->pdo->prepare($sql); + //echo "- $sql -- " . json_encode($parameters, JSON_UNESCAPED_UNICODE) . "\n"; + $stmt->execute($parameters); + return $stmt; + } + + public function getCacheKey(): string + { + return md5(json_encode([ + $this->driver, + $this->address, + $this->port, + $this->database, + $this->tables, + $this->username + ])); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/GenericDefinition.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Database\LazyPdo; + + class GenericDefinition + { + private $pdo; + private $driver; + private $database; + private $typeConverter; + private $reflection; + + public function __construct(LazyPdo $pdo, string $driver, string $database, array $tables) + { + $this->pdo = $pdo; + $this->driver = $driver; + $this->database = $database; + $this->typeConverter = new TypeConverter($driver); + $this->reflection = new GenericReflection($pdo, $driver, $database, $tables); + } + + private function quote(string $identifier): string + { + return '"' . str_replace('"', '', $identifier) . '"'; + } + + public function getColumnType(ReflectedColumn $column, bool $update): string + { + if ($this->driver == 'pgsql' && !$update && $column->getPk() && $this->canAutoIncrement($column)) { + return 'serial'; + } + $type = $this->typeConverter->fromJdbc($column->getType()); + if ($column->hasPrecision() && $column->hasScale()) { + $size = '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; + } elseif ($column->hasPrecision()) { + $size = '(' . $column->getPrecision() . ')'; + } elseif ($column->hasLength()) { + $size = '(' . $column->getLength() . ')'; + } else { + $size = ''; + } + $null = $this->getColumnNullType($column, $update); + $auto = $this->getColumnAutoIncrement($column, $update); + return $type . $size . $null . $auto; + } + + private function getPrimaryKey(string $tableName): string + { + $pks = $this->reflection->getTablePrimaryKeys($tableName); + if (count($pks) == 1) { + return $pks[0]; + } + return ""; + } + + private function canAutoIncrement(ReflectedColumn $column): bool + { + return in_array($column->getType(), ['integer', 'bigint']); + } + + private function getColumnAutoIncrement(ReflectedColumn $column, bool $update): string + { + if (!$this->canAutoIncrement($column)) { + return ''; + } + switch ($this->driver) { + case 'mysql': + return $column->getPk() ? ' AUTO_INCREMENT' : ''; + case 'pgsql': + case 'sqlsrv': + return $column->getPk() ? ' IDENTITY(1,1)' : ''; + case 'sqlite': + return $column->getPk() ? ' AUTOINCREMENT' : ''; + } + } + + private function getColumnNullType(ReflectedColumn $column, bool $update): string + { + if ($this->driver == 'pgsql' && $update) { + return ''; + } + return $column->getNullable() ? ' NULL' : ' NOT NULL'; + } + + private function getTableRenameSQL(string $tableName, string $newTableName): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($newTableName); + + switch ($this->driver) { + case 'mysql': + return "RENAME TABLE $p1 TO $p2"; + case 'pgsql': + return "ALTER TABLE $p1 RENAME TO $p2"; + case 'sqlsrv': + return "EXEC sp_rename $p1, $p2"; + case 'sqlite': + return "ALTER TABLE $p1 RENAME TO $p2"; + } + } + + private function getColumnRenameSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + + switch ($this->driver) { + case 'mysql': + $p4 = $this->getColumnType($newColumn, true); + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + return "ALTER TABLE $p1 RENAME COLUMN $p2 TO $p3"; + case 'sqlsrv': + $p4 = $this->quote($tableName . '.' . $columnName); + return "EXEC sp_rename $p4, $p3, 'COLUMN'"; + case 'sqlite': + return "ALTER TABLE $p1 RENAME COLUMN $p2 TO $p3"; + } + } + + private function getColumnRetypeSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + return "ALTER TABLE $p1 ALTER COLUMN $p3 TYPE $p4"; + case 'sqlsrv': + return "ALTER TABLE $p1 ALTER COLUMN $p3 $p4"; + } + } + + private function getSetColumnNullableSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + $p5 = $newColumn->getNullable() ? 'DROP NOT NULL' : 'SET NOT NULL'; + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p5"; + case 'sqlsrv': + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p4"; + } + } + + private function getSetColumnPkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_pkey'); + + switch ($this->driver) { + case 'mysql': + $p4 = $newColumn->getPk() ? "ADD PRIMARY KEY ($p2)" : 'DROP PRIMARY KEY'; + return "ALTER TABLE $p1 $p4"; + case 'pgsql': + case 'sqlsrv': + $p4 = $newColumn->getPk() ? "ADD CONSTRAINT $p3 PRIMARY KEY ($p2)" : "DROP CONSTRAINT $p3"; + return "ALTER TABLE $p1 $p4"; + } + } + + private function getSetColumnPkSequenceSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + + switch ($this->driver) { + case 'mysql': + return "select 1"; + case 'pgsql': + return $newColumn->getPk() ? "CREATE SEQUENCE $p3 OWNED BY $p1.$p2" : "DROP SEQUENCE $p3"; + case 'sqlsrv': + return $newColumn->getPk() ? "CREATE SEQUENCE $p3" : "DROP SEQUENCE $p3"; + } + } + + private function getSetColumnPkSequenceStartSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + return "select 1"; + case 'pgsql': + $p3 = $this->pdo->quote($tableName . '_' . $columnName . '_seq'); + return "SELECT setval($p3, (SELECT max($p2)+1 FROM $p1));"; + case 'sqlsrv': + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + $p4 = $this->pdo->query("SELECT max($p2)+1 FROM $p1")->fetchColumn(); + return "ALTER SEQUENCE $p3 RESTART WITH $p4"; + } + } + + private function getSetColumnPkDefaultSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + if ($newColumn->getPk()) { + $p3 = $this->pdo->quote($tableName . '_' . $columnName . '_seq'); + $p4 = "SET DEFAULT nextval($p3)"; + } else { + $p4 = 'DROP DEFAULT'; + } + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p4"; + case 'sqlsrv': + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + $p4 = $this->quote($tableName . '_' . $columnName . '_def'); + if ($newColumn->getPk()) { + return "ALTER TABLE $p1 ADD CONSTRAINT $p4 DEFAULT NEXT VALUE FOR $p3 FOR $p2"; + } else { + return "ALTER TABLE $p1 DROP CONSTRAINT $p4"; + } + } + } + + private function getAddColumnFkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_' . $columnName . '_fkey'); + $p4 = $this->quote($newColumn->getFk()); + $p5 = $this->quote($this->getPrimaryKey($newColumn->getFk())); + + return "ALTER TABLE $p1 ADD CONSTRAINT $p3 FOREIGN KEY ($p2) REFERENCES $p4 ($p5)"; + } + + private function getRemoveColumnFkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($tableName . '_' . $columnName . '_fkey'); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 DROP FOREIGN KEY $p2"; + case 'pgsql': + case 'sqlsrv': + return "ALTER TABLE $p1 DROP CONSTRAINT $p2"; + } + } + + private function getAddTableSQL(ReflectedTable $newTable): string + { + $tableName = $newTable->getName(); + $p1 = $this->quote($tableName); + $fields = []; + $constraints = []; + foreach ($newTable->getColumnNames() as $columnName) { + $pkColumn = $this->getPrimaryKey($tableName); + $newColumn = $newTable->getColumn($columnName); + $f1 = $this->quote($columnName); + $f2 = $this->getColumnType($newColumn, false); + $f3 = $this->quote($tableName . '_' . $columnName . '_fkey'); + $f4 = $this->quote($newColumn->getFk()); + $f5 = $this->quote($this->getPrimaryKey($newColumn->getFk())); + $f6 = $this->quote($tableName . '_' . $pkColumn . '_pkey'); + if ($this->driver == 'sqlite') { + if ($newColumn->getPk()) { + $f2 = str_replace('NULL', 'NULL PRIMARY KEY', $f2); + } + $fields[] = "$f1 $f2"; + if ($newColumn->getFk()) { + $constraints[] = "FOREIGN KEY ($f1) REFERENCES $f4 ($f5)"; + } + } else { + $fields[] = "$f1 $f2"; + if ($newColumn->getPk()) { + $constraints[] = "CONSTRAINT $f6 PRIMARY KEY ($f1)"; + } + if ($newColumn->getFk()) { + $constraints[] = "CONSTRAINT $f3 FOREIGN KEY ($f1) REFERENCES $f4 ($f5)"; + } + } + } + $p2 = implode(',', array_merge($fields, $constraints)); + + return "CREATE TABLE $p1 ($p2);"; + } + + private function getAddColumnSQL(string $tableName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($newColumn->getName()); + $p3 = $this->getColumnType($newColumn, false); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ALTER TABLE $p1 ADD COLUMN $p2 $p3"; + case 'sqlsrv': + return "ALTER TABLE $p1 ADD $p2 $p3"; + case 'sqlite': + return "ALTER TABLE $p1 ADD COLUMN $p2 $p3"; + } + } + + private function getRemoveTableSQL(string $tableName): string + { + $p1 = $this->quote($tableName); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "DROP TABLE $p1 CASCADE;"; + case 'sqlsrv': + return "DROP TABLE $p1;"; + case 'sqlite': + return "DROP TABLE $p1;"; + } + } + + private function getRemoveColumnSQL(string $tableName, string $columnName): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ALTER TABLE $p1 DROP COLUMN $p2 CASCADE;"; + case 'sqlsrv': + return "ALTER TABLE $p1 DROP COLUMN $p2;"; + case 'sqlite': + return "ALTER TABLE $p1 DROP COLUMN $p2;"; + } + } + + public function renameTable(string $tableName, string $newTableName) + { + $sql = $this->getTableRenameSQL($tableName, $newTableName); + return $this->query($sql, []); + } + + public function renameColumn(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getColumnRenameSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function retypeColumn(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getColumnRetypeSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function setColumnNullable(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getSetColumnNullableSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function addColumnPrimaryKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getSetColumnPkConstraintSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + if ($this->canAutoIncrement($newColumn)) { + $sql = $this->getSetColumnPkSequenceSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkSequenceStartSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkDefaultSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + } + return true; + } + + public function removeColumnPrimaryKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + if ($this->canAutoIncrement($newColumn)) { + $sql = $this->getSetColumnPkDefaultSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkSequenceSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + } + $sql = $this->getSetColumnPkConstraintSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + return true; + } + + public function addColumnForeignKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getAddColumnFkConstraintSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function removeColumnForeignKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getRemoveColumnFkConstraintSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function addTable(ReflectedTable $newTable) + { + $sql = $this->getAddTableSQL($newTable); + return $this->query($sql, []); + } + + public function addColumn(string $tableName, ReflectedColumn $newColumn) + { + $sql = $this->getAddColumnSQL($tableName, $newColumn); + return $this->query($sql, []); + } + + public function removeTable(string $tableName) + { + $sql = $this->getRemoveTableSQL($tableName); + return $this->query($sql, []); + } + + public function removeColumn(string $tableName, string $columnName) + { + $sql = $this->getRemoveColumnSQL($tableName, $columnName); + return $this->query($sql, []); + } + + private function query(string $sql, array $arguments): bool + { + $stmt = $this->pdo->prepare($sql); + // echo "- $sql -- " . json_encode($arguments) . "\n"; + return $stmt->execute($arguments); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/GenericReflection.php +namespace Tqdev\PhpCrudApi\Database { + + use Tqdev\PhpCrudApi\Database\LazyPdo; + + class GenericReflection + { + private $pdo; + private $driver; + private $database; + private $tables; + private $typeConverter; + + public function __construct(LazyPdo $pdo, string $driver, string $database, array $tables) + { + $this->pdo = $pdo; + $this->driver = $driver; + $this->database = $database; + $this->tables = $tables; + $this->typeConverter = new TypeConverter($driver); + } + + public function getIgnoredTables(): array + { + switch ($this->driver) { + case 'mysql': + return []; + case 'pgsql': + return ['spatial_ref_sys', 'raster_columns', 'raster_overviews', 'geography_columns', 'geometry_columns']; + case 'sqlsrv': + return []; + case 'sqlite': + return []; + } + } + + private function getTablesSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "TABLE_NAME", "TABLE_TYPE" FROM "INFORMATION_SCHEMA"."TABLES" WHERE "TABLE_TYPE" IN (\'BASE TABLE\' , \'VIEW\') AND "TABLE_SCHEMA" = ? ORDER BY BINARY "TABLE_NAME"'; + case 'pgsql': + return 'SELECT c.relname as "TABLE_NAME", c.relkind as "TABLE_TYPE" FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN (\'r\', \'v\') AND n.nspname <> \'pg_catalog\' AND n.nspname <> \'information_schema\' AND n.nspname !~ \'^pg_toast\' AND pg_catalog.pg_table_is_visible(c.oid) AND \'\' <> ? ORDER BY "TABLE_NAME";'; + case 'sqlsrv': + return 'SELECT o.name as "TABLE_NAME", o.xtype as "TABLE_TYPE" FROM sysobjects o WHERE o.xtype IN (\'U\', \'V\') ORDER BY "TABLE_NAME"'; + case 'sqlite': + return 'SELECT t.name as "TABLE_NAME", t.type as "TABLE_TYPE" FROM sqlite_master t WHERE t.type IN (\'table\', \'view\') AND \'\' <> ? ORDER BY "TABLE_NAME"'; + } + } + + private function getTableColumnsSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME", "IS_NULLABLE", "DATA_TYPE", "CHARACTER_MAXIMUM_LENGTH" as "CHARACTER_MAXIMUM_LENGTH", "NUMERIC_PRECISION", "NUMERIC_SCALE", "COLUMN_TYPE" FROM "INFORMATION_SCHEMA"."COLUMNS" WHERE "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ? ORDER BY "ORDINAL_POSITION"'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME", case when a.attnotnull then \'NO\' else \'YES\' end as "IS_NULLABLE", pg_catalog.format_type(a.atttypid, -1) as "DATA_TYPE", case when a.atttypmod < 0 then NULL else a.atttypmod-4 end as "CHARACTER_MAXIMUM_LENGTH", case when a.atttypid != 1700 then NULL else ((a.atttypmod - 4) >> 16) & 65535 end as "NUMERIC_PRECISION", case when a.atttypid != 1700 then NULL else (a.atttypmod - 4) & 65535 end as "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM pg_attribute a JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum;'; + case 'sqlsrv': + return 'SELECT c.name AS "COLUMN_NAME", c.is_nullable AS "IS_NULLABLE", t.Name AS "DATA_TYPE", (c.max_length/2) AS "CHARACTER_MAXIMUM_LENGTH", c.precision AS "NUMERIC_PRECISION", c.scale AS "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM sys.columns c INNER JOIN sys.types t ON c.user_type_id = t.user_type_id WHERE c.object_id = OBJECT_ID(?) AND \'\' <> ? ORDER BY c.column_id'; + case 'sqlite': + return 'SELECT "name" AS "COLUMN_NAME", case when "notnull"==1 then \'no\' else \'yes\' end as "IS_NULLABLE", lower("type") AS "DATA_TYPE", 2147483647 AS "CHARACTER_MAXIMUM_LENGTH", 0 AS "NUMERIC_PRECISION", 0 AS "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM pragma_table_info(?) WHERE \'\' <> ? ORDER BY "cid"'; + } + } + + private function getTablePrimaryKeysSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME" FROM "INFORMATION_SCHEMA"."KEY_COLUMN_USAGE" WHERE "CONSTRAINT_NAME" = \'PRIMARY\' AND "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ?'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME" FROM pg_attribute a JOIN pg_constraint c ON (c.conrelid, c.conkey[1]) = (a.attrelid, a.attnum) JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND c.contype = \'p\''; + case 'sqlsrv': + return 'SELECT c.NAME as "COLUMN_NAME" FROM sys.key_constraints kc inner join sys.objects t on t.object_id = kc.parent_object_id INNER JOIN sys.index_columns ic ON kc.parent_object_id = ic.object_id and kc.unique_index_id = ic.index_id INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id WHERE kc.type = \'PK\' and t.object_id = OBJECT_ID(?) and \'\' <> ?'; + case 'sqlite': + return 'SELECT "name" as "COLUMN_NAME" FROM pragma_table_info(?) WHERE "pk"=1 AND \'\' <> ?'; + } + } + + private function getTableForeignKeysSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME", "REFERENCED_TABLE_NAME" FROM "INFORMATION_SCHEMA"."KEY_COLUMN_USAGE" WHERE "REFERENCED_TABLE_NAME" IS NOT NULL AND "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ?'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME", c.confrelid::regclass::text AS "REFERENCED_TABLE_NAME" FROM pg_attribute a JOIN pg_constraint c ON (c.conrelid, c.conkey[1]) = (a.attrelid, a.attnum) JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND c.contype = \'f\''; + case 'sqlsrv': + return 'SELECT COL_NAME(fc.parent_object_id, fc.parent_column_id) AS "COLUMN_NAME", OBJECT_NAME (f.referenced_object_id) AS "REFERENCED_TABLE_NAME" FROM sys.foreign_keys AS f INNER JOIN sys.foreign_key_columns AS fc ON f.OBJECT_ID = fc.constraint_object_id WHERE f.parent_object_id = OBJECT_ID(?) and \'\' <> ?'; + case 'sqlite': + return 'SELECT "from" AS "COLUMN_NAME", "table" AS "REFERENCED_TABLE_NAME" FROM pragma_foreign_key_list(?) WHERE \'\' <> ?'; + } + } + + public function getDatabaseName(): string + { + return $this->database; + } + + public function getTables(): array + { + $sql = $this->getTablesSQL(); + $results = $this->query($sql, [$this->database]); + $tables = $this->tables; + $results = array_filter($results, function ($v) use ($tables) { + return !$tables || in_array($v['TABLE_NAME'], $tables); + }); + foreach ($results as &$result) { + $map = []; + switch ($this->driver) { + case 'mysql': + $map = ['BASE TABLE' => 'table', 'VIEW' => 'view']; + break; + case 'pgsql': + $map = ['r' => 'table', 'v' => 'view']; + break; + case 'sqlsrv': + $map = ['U' => 'table', 'V' => 'view']; + break; + case 'sqlite': + $map = ['table' => 'table', 'view' => 'view']; + break; + } + $result['TABLE_TYPE'] = $map[trim($result['TABLE_TYPE'])]; + } + return $results; + } + + public function getTableColumns(string $tableName, string $type): array + { + $sql = $this->getTableColumnsSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + if ($type == 'view') { + foreach ($results as &$result) { + $result['IS_NULLABLE'] = false; + } + } + if ($this->driver == 'mysql') { + foreach ($results as &$result) { + // mysql does not properly reflect display width of types + preg_match('|([a-z]+)(\(([0-9]+)(,([0-9]+))?\))?|', $result['DATA_TYPE'], $matches); + $result['DATA_TYPE'] = $matches[1]; + if (!$result['CHARACTER_MAXIMUM_LENGTH']) { + if (isset($matches[3])) { + $result['NUMERIC_PRECISION'] = $matches[3]; + } + if (isset($matches[5])) { + $result['NUMERIC_SCALE'] = $matches[5]; + } + } + } + } + if ($this->driver == 'sqlite') { + foreach ($results as &$result) { + // sqlite does not properly reflect display width of types + preg_match('|([a-z]+)(\(([0-9]+)(,([0-9]+))?\))?|', $result['DATA_TYPE'], $matches); + if (isset($matches[1])) { + $result['DATA_TYPE'] = $matches[1]; + } else { + $result['DATA_TYPE'] = 'integer'; + } + if (isset($matches[5])) { + $result['NUMERIC_PRECISION'] = $matches[3]; + $result['NUMERIC_SCALE'] = $matches[5]; + } else if (isset($matches[3])) { + $result['CHARACTER_MAXIMUM_LENGTH'] = $matches[3]; + } + } + } + return $results; + } + + public function getTablePrimaryKeys(string $tableName): array + { + $sql = $this->getTablePrimaryKeysSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + $primaryKeys = []; + foreach ($results as $result) { + $primaryKeys[] = $result['COLUMN_NAME']; + } + return $primaryKeys; + } + + public function getTableForeignKeys(string $tableName): array + { + $sql = $this->getTableForeignKeysSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + $foreignKeys = []; + foreach ($results as $result) { + $foreignKeys[$result['COLUMN_NAME']] = $result['REFERENCED_TABLE_NAME']; + } + return $foreignKeys; + } + + public function toJdbcType(string $type, string $size): string + { + return $this->typeConverter->toJdbc($type, $size); + } + + private function query(string $sql, array $parameters): array + { + $stmt = $this->pdo->prepare($sql); + //echo "- $sql -- " . json_encode($parameters, JSON_UNESCAPED_UNICODE) . "\n"; + $stmt->execute($parameters); + return $stmt->fetchAll(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/LazyPdo.php +namespace Tqdev\PhpCrudApi\Database { + + class LazyPdo extends \PDO + { + private $dsn; + private $user; + private $password; + private $options; + private $commands; + + private $pdo = null; + + public function __construct(string $dsn, /*?string*/ $user = null, /*?string*/ $password = null, array $options = array()) + { + $this->dsn = $dsn; + $this->user = $user; + $this->password = $password; + $this->options = $options; + $this->commands = array(); + // explicitly NOT calling super::__construct + } + + public function addInitCommand(string $command)/*: void*/ + { + $this->commands[] = $command; + } + + private function pdo() + { + if (!$this->pdo) { + $this->pdo = new \PDO($this->dsn, $this->user, $this->password, $this->options); + foreach ($this->commands as $command) { + $this->pdo->query($command); + } + } + return $this->pdo; + } + + public function reconstruct(string $dsn, /*?string*/ $user = null, /*?string*/ $password = null, array $options = array()): bool + { + $this->dsn = $dsn; + $this->user = $user; + $this->password = $password; + $this->options = $options; + $this->commands = array(); + if ($this->pdo) { + $this->pdo = null; + return true; + } + return false; + } + + public function inTransaction(): bool + { + // Do not call parent method if there is no pdo object + return $this->pdo && parent::inTransaction(); + } + + public function setAttribute($attribute, $value): bool + { + if ($this->pdo) { + return $this->pdo()->setAttribute($attribute, $value); + } + $this->options[$attribute] = $value; + return true; + } + + public function getAttribute($attribute): mixed + { + return $this->pdo()->getAttribute($attribute); + } + + public function beginTransaction(): bool + { + return $this->pdo()->beginTransaction(); + } + + public function commit(): bool + { + return $this->pdo()->commit(); + } + + public function rollBack(): bool + { + return $this->pdo()->rollBack(); + } + + public function errorCode(): mixed + { + return $this->pdo()->errorCode(); + } + + public function errorInfo(): array + { + return $this->pdo()->errorInfo(); + } + + public function exec($query): int + { + return $this->pdo()->exec($query); + } + + public function prepare($statement, $options = array()) + { + return $this->pdo()->prepare($statement, $options); + } + + public function quote($string, $parameter_type = null): string + { + return $this->pdo()->quote($string, $parameter_type); + } + + public function lastInsertId(/* ?string */$name = null): string + { + return $this->pdo()->lastInsertId($name); + } + + public function query(string $statement): \PDOStatement + { + return call_user_func_array(array($this->pdo(), 'query'), func_get_args()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Database/TypeConverter.php +namespace Tqdev\PhpCrudApi\Database { + + class TypeConverter + { + private $driver; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + private $fromJdbc = [ + 'mysql' => [ + 'clob' => 'longtext', + 'boolean' => 'tinyint(1)', + 'blob' => 'longblob', + 'timestamp' => 'datetime', + ], + 'pgsql' => [ + 'clob' => 'text', + 'blob' => 'bytea', + 'float' => 'real', + 'double' => 'double precision', + 'varbinary' => 'bytea', + ], + 'sqlsrv' => [ + 'boolean' => 'bit', + 'varchar' => 'nvarchar', + 'clob' => 'ntext', + 'blob' => 'image', + 'time' => 'time(0)', + 'timestamp' => 'datetime2(0)', + 'double' => 'float', + 'float' => 'real', + ], + ]; + + private $toJdbc = [ + 'simplified' => [ + 'char' => 'varchar', + 'longvarchar' => 'clob', + 'nchar' => 'varchar', + 'nvarchar' => 'varchar', + 'longnvarchar' => 'clob', + 'binary' => 'varbinary', + 'longvarbinary' => 'blob', + 'tinyint' => 'integer', + 'smallint' => 'integer', + 'real' => 'float', + 'numeric' => 'decimal', + 'nclob' => 'clob', + 'time_with_timezone' => 'time', + 'timestamp_with_timezone' => 'timestamp', + ], + 'mysql' => [ + 'tinyint(1)' => 'boolean', + 'bit(1)' => 'boolean', + 'tinyblob' => 'blob', + 'mediumblob' => 'blob', + 'longblob' => 'blob', + 'tinytext' => 'clob', + 'mediumtext' => 'clob', + 'longtext' => 'clob', + 'text' => 'clob', + 'mediumint' => 'integer', + 'int' => 'integer', + 'polygon' => 'geometry', + 'point' => 'geometry', + 'datetime' => 'timestamp', + 'year' => 'integer', + 'enum' => 'varchar', + 'set' => 'varchar', + 'json' => 'clob', + ], + 'pgsql' => [ + 'bigserial' => 'bigint', + 'bit varying' => 'bit', + 'box' => 'geometry', + 'bytea' => 'blob', + 'bpchar' => 'char', + 'character varying' => 'varchar', + 'character' => 'char', + 'cidr' => 'varchar', + 'circle' => 'geometry', + 'double precision' => 'double', + 'inet' => 'integer', + //'interval [ fields ]' + 'json' => 'clob', + 'jsonb' => 'clob', + 'line' => 'geometry', + 'lseg' => 'geometry', + 'macaddr' => 'varchar', + 'money' => 'decimal', + 'path' => 'geometry', + 'point' => 'geometry', + 'polygon' => 'geometry', + 'real' => 'float', + 'serial' => 'integer', + 'text' => 'clob', + 'time without time zone' => 'time', + 'time with time zone' => 'time_with_timezone', + 'timestamp without time zone' => 'timestamp', + 'timestamp with time zone' => 'timestamp_with_timezone', + //'tsquery'= + //'tsvector' + //'txid_snapshot' + 'uuid' => 'char', + 'xml' => 'clob', + ], + // source: https://docs.microsoft.com/en-us/sql/connect/jdbc/using-basic-data-types?view=sql-server-2017 + 'sqlsrv' => [ + 'varbinary()' => 'blob', + 'bit' => 'boolean', + 'datetime' => 'timestamp', + 'datetime2' => 'timestamp', + 'float' => 'double', + 'image' => 'blob', + 'int' => 'integer', + 'money' => 'decimal', + 'ntext' => 'clob', + 'smalldatetime' => 'timestamp', + 'smallmoney' => 'decimal', + 'text' => 'clob', + 'timestamp' => 'binary', + 'udt' => 'varbinary', + 'uniqueidentifier' => 'char', + 'xml' => 'clob', + ], + 'sqlite' => [ + 'tinytext' => 'clob', + 'text' => 'clob', + 'mediumtext' => 'clob', + 'longtext' => 'clob', + 'mediumint' => 'integer', + 'int' => 'integer', + 'bigint' => 'bigint', + 'int2' => 'smallint', + 'int4' => 'integer', + 'int8' => 'bigint', + 'double precision' => 'double', + 'datetime' => 'timestamp' + ], + ]; + + // source: https://docs.oracle.com/javase/9/docs/api/java/sql/Types.html + private $valid = [ + //'array' => true, + 'bigint' => true, + 'binary' => true, + 'bit' => true, + 'blob' => true, + 'boolean' => true, + 'char' => true, + 'clob' => true, + //'datalink' => true, + 'date' => true, + 'decimal' => true, + //'distinct' => true, + 'double' => true, + 'float' => true, + 'integer' => true, + //'java_object' => true, + 'longnvarchar' => true, + 'longvarbinary' => true, + 'longvarchar' => true, + 'nchar' => true, + 'nclob' => true, + //'null' => true, + 'numeric' => true, + 'nvarchar' => true, + //'other' => true, + 'real' => true, + //'ref' => true, + //'ref_cursor' => true, + //'rowid' => true, + 'smallint' => true, + //'sqlxml' => true, + //'struct' => true, + 'time' => true, + 'time_with_timezone' => true, + 'timestamp' => true, + 'timestamp_with_timezone' => true, + 'tinyint' => true, + 'varbinary' => true, + 'varchar' => true, + // extra: + 'geometry' => true, + ]; + + public function toJdbc(string $type, string $size): string + { + $jdbcType = strtolower($type); + if (isset($this->toJdbc[$this->driver]["$jdbcType($size)"])) { + $jdbcType = $this->toJdbc[$this->driver]["$jdbcType($size)"]; + } + if (isset($this->toJdbc[$this->driver][$jdbcType])) { + $jdbcType = $this->toJdbc[$this->driver][$jdbcType]; + } + if (isset($this->toJdbc['simplified'][$jdbcType])) { + $jdbcType = $this->toJdbc['simplified'][$jdbcType]; + } + if (!isset($this->valid[$jdbcType])) { + //throw new \Exception("Unsupported type '$jdbcType' for driver '$this->driver'"); + $jdbcType = 'clob'; + } + return $jdbcType; + } + + public function fromJdbc(string $type): string + { + $jdbcType = strtolower($type); + if (isset($this->fromJdbc[$this->driver][$jdbcType])) { + $jdbcType = $this->fromJdbc[$this->driver][$jdbcType]; + } + return $jdbcType; + } + } +} + +// file: src/Tqdev/PhpCrudApi/GeoJson/Feature.php +namespace Tqdev\PhpCrudApi\GeoJson { + + class Feature implements \JsonSerializable + { + private $id; + private $properties; + private $geometry; + + public function __construct($id, array $properties, /*?Geometry*/ $geometry) + { + $this->id = $id; + $this->properties = $properties; + $this->geometry = $geometry; + } + + public function serialize() + { + return [ + 'type' => 'Feature', + 'id' => $this->id, + 'properties' => $this->properties, + 'geometry' => $this->geometry, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/GeoJson/FeatureCollection.php +namespace Tqdev\PhpCrudApi\GeoJson { + + class FeatureCollection implements \JsonSerializable + { + private $features; + + private $results; + + public function __construct(array $features, int $results) + { + $this->features = $features; + $this->results = $results; + } + + public function serialize() + { + return [ + 'type' => 'FeatureCollection', + 'features' => $this->features, + 'results' => $this->results, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize(), function ($v) { + return $v !== 0; + }); + } + } +} + +// file: src/Tqdev/PhpCrudApi/GeoJson/GeoJsonService.php +namespace Tqdev\PhpCrudApi\GeoJson { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\GeoJson\FeatureCollection; + use Tqdev\PhpCrudApi\Record\RecordService; + + class GeoJsonService + { + private $reflection; + private $records; + + public function __construct(ReflectionService $reflection, RecordService $records) + { + $this->reflection = $reflection; + $this->records = $records; + } + + public function hasTable(string $table): bool + { + return $this->reflection->hasTable($table); + } + + public function getType(string $table): string + { + return $this->reflection->getType($table); + } + + private function getGeometryColumnName(string $tableName, array &$params): string + { + $geometryParam = isset($params['geometry']) ? $params['geometry'][0] : ''; + $table = $this->reflection->getTable($tableName); + $geometryColumnName = ''; + foreach ($table->getColumnNames() as $columnName) { + if ($geometryParam && $geometryParam != $columnName) { + continue; + } + $column = $table->getColumn($columnName); + if ($column->isGeometry()) { + $geometryColumnName = $columnName; + break; + } + } + if ($geometryColumnName) { + $params['mandatory'][] = $tableName . "." . $geometryColumnName; + } + return $geometryColumnName; + } + + private function setBoudingBoxFilter(string $geometryColumnName, array &$params) + { + $boundingBox = isset($params['bbox']) ? $params['bbox'][0] : ''; + if ($boundingBox) { + $c = explode(',', $boundingBox); + if (!isset($params['filter'])) { + $params['filter'] = array(); + } + $params['filter'][] = "$geometryColumnName,sin,POLYGON(($c[0] $c[1],$c[2] $c[1],$c[2] $c[3],$c[0] $c[3],$c[0] $c[1]))"; + } + $tile = isset($params['tile']) ? $params['tile'][0] : ''; + if ($tile) { + $zxy = explode(',', $tile); + if (count($zxy) == 3) { + list($z, $x, $y) = $zxy; + $c = array(); + $c = array_merge($c, $this->convertTileToLatLonOfUpperLeftCorner($z, $x, $y)); + $c = array_merge($c, $this->convertTileToLatLonOfUpperLeftCorner($z, $x + 1, $y + 1)); + $params['filter'][] = "$geometryColumnName,sin,POLYGON(($c[0] $c[1],$c[2] $c[1],$c[2] $c[3],$c[0] $c[3],$c[0] $c[1]))"; + } + } + } + + private function convertTileToLatLonOfUpperLeftCorner($z, $x, $y): array + { + $n = pow(2, $z); + $lon = $x / $n * 360.0 - 180.0; + $lat = rad2deg(atan(sinh(pi() * (1 - 2 * $y / $n)))); + return [$lon, $lat]; + } + + private function convertRecordToFeature(/*object*/$record, string $primaryKeyColumnName, string $geometryColumnName) + { + $id = null; + if ($primaryKeyColumnName) { + $id = $record[$primaryKeyColumnName]; + } + $geometry = null; + if (isset($record[$geometryColumnName])) { + $geometry = Geometry::fromWkt($record[$geometryColumnName]); + } + $properties = array_diff_key($record, [$primaryKeyColumnName => true, $geometryColumnName => true]); + return new Feature($id, $properties, $geometry); + } + + private function getPrimaryKeyColumnName(string $tableName, array &$params): string + { + $primaryKeyColumn = $this->reflection->getTable($tableName)->getPk(); + if (!$primaryKeyColumn) { + return ''; + } + $primaryKeyColumnName = $primaryKeyColumn->getName(); + $params['mandatory'][] = $tableName . "." . $primaryKeyColumnName; + return $primaryKeyColumnName; + } + + public function _list(string $tableName, array $params): FeatureCollection + { + $geometryColumnName = $this->getGeometryColumnName($tableName, $params); + $this->setBoudingBoxFilter($geometryColumnName, $params); + $primaryKeyColumnName = $this->getPrimaryKeyColumnName($tableName, $params); + $records = $this->records->_list($tableName, $params); + $features = array(); + foreach ($records->getRecords() as $record) { + $features[] = $this->convertRecordToFeature($record, $primaryKeyColumnName, $geometryColumnName); + } + return new FeatureCollection($features, $records->getResults()); + } + + public function read(string $tableName, string $id, array $params): Feature + { + $geometryColumnName = $this->getGeometryColumnName($tableName, $params); + $primaryKeyColumnName = $this->getPrimaryKeyColumnName($tableName, $params); + $record = $this->records->read($tableName, $id, $params); + return $this->convertRecordToFeature($record, $primaryKeyColumnName, $geometryColumnName); + } + } +} + +// file: src/Tqdev/PhpCrudApi/GeoJson/Geometry.php +namespace Tqdev\PhpCrudApi\GeoJson { + + class Geometry implements \JsonSerializable + { + private $type; + private $geometry; + + public static $types = [ + "Point", + "MultiPoint", + "LineString", + "MultiLineString", + "Polygon", + "MultiPolygon", + //"GeometryCollection", + ]; + + public function __construct(string $type, array $coordinates) + { + $this->type = $type; + $this->coordinates = $coordinates; + } + + public static function fromWkt(string $wkt): Geometry + { + $bracket = strpos($wkt, '('); + $type = strtoupper(trim(substr($wkt, 0, $bracket))); + $supported = false; + foreach (Geometry::$types as $typeName) { + if (strtoupper($typeName) == $type) { + $type = $typeName; + $supported = true; + } + } + if (!$supported) { + throw new \Exception('Geometry type not supported: ' . $type); + } + $coordinates = substr($wkt, $bracket); + if (substr($type, -5) != 'Point' || ($type == 'MultiPoint' && $coordinates[1] != '(')) { + $coordinates = preg_replace('|([0-9\-\.]+ )+([0-9\-\.]+)|', '[\1\2]', $coordinates); + } + $coordinates = str_replace(['(', ')', ', ', ' '], ['[', ']', ',', ','], $coordinates); + $coordinates = json_decode($coordinates); + if (!$coordinates) { + throw new \Exception('Could not decode WKT: ' . $wkt); + } + return new Geometry($type, $coordinates); + } + + public function serialize() + { + return [ + 'type' => $this->type, + 'coordinates' => $this->coordinates, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/Base/Middleware.php +namespace Tqdev\PhpCrudApi\Middleware\Base { + + use Psr\Http\Server\MiddlewareInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + + abstract class Middleware implements MiddlewareInterface + { + protected $next; + protected $responder; + private $properties; + + public function __construct(Router $router, Responder $responder, array $properties) + { + $router->load($this); + $this->responder = $responder; + $this->properties = $properties; + } + + protected function getArrayProperty(string $key, string $default): array + { + return array_filter(array_map('trim', explode(',', $this->getProperty($key, $default)))); + } + + protected function getMapProperty(string $key, string $default): array + { + $pairs = $this->getArrayProperty($key, $default); + $result = array(); + foreach ($pairs as $pair) { + if (strpos($pair, ':')) { + list($k, $v) = explode(':', $pair, 2); + $result[trim($k)] = trim($v); + } else { + $result[] = trim($pair); + } + } + return $result; + } + + protected function getProperty(string $key, $default) + { + return isset($this->properties[$key]) ? $this->properties[$key] : $default; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/Communication/VariableStore.php +namespace Tqdev\PhpCrudApi\Middleware\Communication { + + class VariableStore + { + public static $values = array(); + + public static function get(string $key) + { + if (isset(self::$values[$key])) { + return self::$values[$key]; + } + return null; + } + + public static function set(string $key, /* object */ $value) + { + self::$values[$key] = $value; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/Router/Router.php +namespace Tqdev\PhpCrudApi\Middleware\Router { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + + interface Router extends RequestHandlerInterface + { + public function register(string $method, string $path, array $handler); + + public function load(Middleware $middleware); + + public function route(ServerRequestInterface $request): ResponseInterface; + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/Router/SimpleRouter.php +namespace Tqdev\PhpCrudApi\Middleware\Router { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Cache\Cache; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\PathTree; + use Tqdev\PhpCrudApi\RequestUtils; + use Tqdev\PhpCrudApi\ResponseUtils; + + class SimpleRouter implements Router + { + private $basePath; + private $responder; + private $cache; + private $ttl; + private $debug; + private $registration; + private $routes; + private $routeHandlers; + private $middlewares; + + public function __construct(string $basePath, Responder $responder, Cache $cache, int $ttl, bool $debug) + { + $this->basePath = rtrim($this->detectBasePath($basePath), '/'); + $this->responder = $responder; + $this->cache = $cache; + $this->ttl = $ttl; + $this->debug = $debug; + $this->registration = true; + $this->routes = $this->loadPathTree(); + $this->routeHandlers = []; + $this->middlewares = array(); + } + + private function detectBasePath(string $basePath): string + { + if ($basePath) { + return $basePath; + } + if (isset($_SERVER['REQUEST_URI'])) { + $fullPath = urldecode(explode('?', $_SERVER['REQUEST_URI'])[0]); + if (isset($_SERVER['PATH_INFO'])) { + $path = $_SERVER['PATH_INFO']; + if (substr($fullPath, -1 * strlen($path)) == $path) { + return substr($fullPath, 0, -1 * strlen($path)); + } + } + if ('/' . basename(__FILE__) == $fullPath) { + return $fullPath; + } + } + return '/'; + } + + private function loadPathTree(): PathTree + { + $data = $this->cache->get('PathTree'); + if ($data != '') { + $tree = PathTree::fromJson(json_decode(gzuncompress($data))); + $this->registration = false; + } else { + $tree = new PathTree(); + } + return $tree; + } + + public function register(string $method, string $path, array $handler) + { + $routeNumber = count($this->routeHandlers); + $this->routeHandlers[$routeNumber] = $handler; + if ($this->registration) { + $path = trim($path, '/'); + $parts = array(); + if ($path) { + $parts = explode('/', $path); + } + array_unshift($parts, $method); + $this->routes->put($parts, $routeNumber); + } + } + + public function load(Middleware $middleware) /*: void*/ + { + array_push($this->middlewares, $middleware); + } + + public function route(ServerRequestInterface $request): ResponseInterface + { + if ($this->registration) { + $data = gzcompress(json_encode($this->routes, JSON_UNESCAPED_UNICODE)); + $this->cache->set('PathTree', $data, $this->ttl); + } + + return $this->handle($request); + } + + private function getRouteNumbers(ServerRequestInterface $request): array + { + $method = strtoupper($request->getMethod()); + $path = array(); + $segment = $method; + for ($i = 1; strlen($segment) > 0; $i++) { + array_push($path, $segment); + $segment = RequestUtils::getPathSegment($request, $i); + } + return $this->routes->match($path); + } + + private function removeBasePath(ServerRequestInterface $request): ServerRequestInterface + { + $path = $request->getUri()->getPath(); + if (substr($path, 0, strlen($this->basePath)) == $this->basePath) { + $path = substr($path, strlen($this->basePath)); + $request = $request->withUri($request->getUri()->withPath($path)); + } + return $request; + } + + public function getBasePath(): string + { + return $this->basePath; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $request = $this->removeBasePath($request); + + if (count($this->middlewares)) { + $handler = array_pop($this->middlewares); + return $handler->process($request, $this); + } + + $routeNumbers = $this->getRouteNumbers($request); + if (count($routeNumbers) == 0) { + return $this->responder->error(ErrorCode::ROUTE_NOT_FOUND, $request->getUri()->getPath()); + } + try { + $response = call_user_func($this->routeHandlers[$routeNumbers[0]], $request); + } catch (\PDOException $e) { + if (strpos(strtolower($e->getMessage()), 'duplicate') !== false) { + $response = $this->responder->error(ErrorCode::DUPLICATE_KEY_EXCEPTION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'unique constraint') !== false) { + $response = $this->responder->error(ErrorCode::DUPLICATE_KEY_EXCEPTION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'default value') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'allow nulls') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'constraint') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } else { + $response = $this->responder->error(ErrorCode::ERROR_NOT_FOUND, ''); + } + if ($this->debug) { + $response = ResponseUtils::addExceptionHeaders($response, $e); + } + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/AjaxOnlyMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class AjaxOnlyMiddleware extends Middleware + { + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $method = $request->getMethod(); + $excludeMethods = $this->getArrayProperty('excludeMethods', 'OPTIONS,GET'); + if (!in_array($method, $excludeMethods)) { + $headerName = $this->getProperty('headerName', 'X-Requested-With'); + $headerValue = $this->getProperty('headerValue', 'XMLHttpRequest'); + if ($headerValue != RequestUtils::getHeader($request, $headerName)) { + return $this->responder->error(ErrorCode::ONLY_AJAX_REQUESTS_ALLOWED, $method); + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/AuthorizationMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\FilterInfo; + use Tqdev\PhpCrudApi\RequestUtils; + + class AuthorizationMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function handleColumns(string $operation, string $tableName) /*: void*/ + { + $columnHandler = $this->getProperty('columnHandler', ''); + if ($columnHandler) { + $table = $this->reflection->getTable($tableName); + foreach ($table->getColumnNames() as $columnName) { + $allowed = call_user_func($columnHandler, $operation, $tableName, $columnName); + if (!$allowed) { + $table->removeColumn($columnName); + } + } + } + } + + private function handleTable(string $operation, string $tableName) /*: void*/ + { + if (!$this->reflection->hasTable($tableName)) { + return; + } + $allowed = true; + $tableHandler = $this->getProperty('tableHandler', ''); + if ($tableHandler) { + $allowed = call_user_func($tableHandler, $operation, $tableName); + } + if (!$allowed) { + $this->reflection->removeTable($tableName); + } else { + $this->handleColumns($operation, $tableName); + } + } + + private function handleRecords(string $operation, string $tableName) /*: void*/ + { + if (!$this->reflection->hasTable($tableName)) { + return; + } + $recordHandler = $this->getProperty('recordHandler', ''); + if ($recordHandler) { + $query = call_user_func($recordHandler, $operation, $tableName); + $filters = new FilterInfo(); + $table = $this->reflection->getTable($tableName); + $query = str_replace('][]=', ']=', str_replace('=', '[]=', $query)); + parse_str($query, $params); + $condition = $filters->getCombinedConditions($table, $params); + VariableStore::set("authorization.conditions.$tableName", $condition); + } + } + + private function pathHandler(string $path) /*: bool*/ + { + $pathHandler = $this->getProperty('pathHandler', ''); + return $pathHandler ? call_user_func($pathHandler, $path) : true; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $path = RequestUtils::getPathSegment($request, 1); + + if (!$this->pathHandler($path)) { + return $this->responder->error(ErrorCode::ROUTE_NOT_FOUND, $request->getUri()->getPath()); + } + + $operation = RequestUtils::getOperation($request); + $tableNames = RequestUtils::getTableNames($request, $this->reflection); + foreach ($tableNames as $tableName) { + $this->handleTable($operation, $tableName); + if ($path == 'records') { + $this->handleRecords($operation, $tableName); + } + } + if ($path == 'openapi') { + VariableStore::set('authorization.tableHandler', $this->getProperty('tableHandler', '')); + VariableStore::set('authorization.columnHandler', $this->getProperty('columnHandler', '')); + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class BasicAuthMiddleware extends Middleware + { + private function hasCorrectPassword(string $username, string $password, array &$passwords): bool + { + $hash = isset($passwords[$username]) ? $passwords[$username] : false; + if ($hash && password_verify($password, $hash)) { + if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { + $passwords[$username] = password_hash($password, PASSWORD_DEFAULT); + } + return true; + } + return false; + } + + private function getValidUsername(string $username, string $password, string $passwordFile): string + { + $passwords = $this->readPasswords($passwordFile); + $valid = $this->hasCorrectPassword($username, $password, $passwords); + $this->writePasswords($passwordFile, $passwords); + return $valid ? $username : ''; + } + + private function readPasswords(string $passwordFile): array + { + $passwords = []; + $passwordLines = file($passwordFile); + foreach ($passwordLines as $passwordLine) { + if (strpos($passwordLine, ':') !== false) { + list($username, $hash) = explode(':', trim($passwordLine), 2); + if (strlen($hash) > 0 && $hash[0] != '$') { + $hash = password_hash($hash, PASSWORD_DEFAULT); + } + $passwords[$username] = $hash; + } + } + return $passwords; + } + + private function writePasswords(string $passwordFile, array $passwords): bool + { + $success = false; + $passwordFileContents = ''; + foreach ($passwords as $username => $hash) { + $passwordFileContents .= "$username:$hash\n"; + } + if (file_get_contents($passwordFile) != $passwordFileContents) { + $success = file_put_contents($passwordFile, $passwordFileContents) !== false; + } + return $success; + } + + private function getAuthorizationCredentials(ServerRequestInterface $request): string + { + if (isset($_SERVER['PHP_AUTH_USER'])) { + return $_SERVER['PHP_AUTH_USER'] . ':' . $_SERVER['PHP_AUTH_PW']; + } + $header = RequestUtils::getHeader($request, 'Authorization'); + $parts = explode(' ', trim($header), 2); + if (count($parts) != 2) { + return ''; + } + if ($parts[0] != 'Basic') { + return ''; + } + return base64_decode(strtr($parts[1], '-_', '+/')); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $credentials = $this->getAuthorizationCredentials($request); + if ($credentials) { + list($username, $password) = array('', ''); + if (strpos($credentials, ':') !== false) { + list($username, $password) = explode(':', $credentials, 2); + } + $passwordFile = $this->getProperty('passwordFile', '.htpasswd'); + $validUser = $this->getValidUsername($username, $password, $passwordFile); + $_SESSION['username'] = $validUser; + if (!$validUser) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (!headers_sent()) { + session_regenerate_id(); + } + } + if (!isset($_SESSION['username']) || !$_SESSION['username']) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + $response = $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + $realm = $this->getProperty('realm', 'Username and password required'); + $response = $response->withHeader('WWW-Authenticate', "Basic realm=\"$realm\""); + return $response; + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/CorsMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\ResponseFactory; + use Tqdev\PhpCrudApi\ResponseUtils; + + class CorsMiddleware extends Middleware + { + private $debug; + + public function __construct(Router $router, Responder $responder, array $properties, bool $debug) + { + parent::__construct($router, $responder, $properties); + $this->debug = $debug; + } + + private function isOriginAllowed(string $origin, string $allowedOrigins): bool + { + $found = false; + foreach (explode(',', $allowedOrigins) as $allowedOrigin) { + $hostname = preg_quote(strtolower(trim($allowedOrigin))); + $regex = '/^' . str_replace('\*', '.*', $hostname) . '$/'; + if (preg_match($regex, $origin)) { + $found = true; + break; + } + } + return $found; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $method = $request->getMethod(); + $origin = count($request->getHeader('Origin')) ? $request->getHeader('Origin')[0] : ''; + $allowedOrigins = $this->getProperty('allowedOrigins', '*'); + if ($origin && !$this->isOriginAllowed($origin, $allowedOrigins)) { + $response = $this->responder->error(ErrorCode::ORIGIN_FORBIDDEN, $origin); + } elseif ($method == 'OPTIONS') { + $response = ResponseFactory::fromStatus(ResponseFactory::OK); + $allowHeaders = $this->getProperty('allowHeaders', 'Content-Type, X-XSRF-TOKEN, X-Authorization'); + if ($this->debug) { + $allowHeaders = implode(', ', array_filter([$allowHeaders, 'X-Exception-Name, X-Exception-Message, X-Exception-File'])); + } + if ($allowHeaders) { + $response = $response->withHeader('Access-Control-Allow-Headers', $allowHeaders); + } + $allowMethods = $this->getProperty('allowMethods', 'OPTIONS, GET, PUT, POST, DELETE, PATCH'); + if ($allowMethods) { + $response = $response->withHeader('Access-Control-Allow-Methods', $allowMethods); + } + $allowCredentials = $this->getProperty('allowCredentials', 'true'); + if ($allowCredentials) { + $response = $response->withHeader('Access-Control-Allow-Credentials', $allowCredentials); + } + $maxAge = $this->getProperty('maxAge', '1728000'); + if ($maxAge) { + $response = $response->withHeader('Access-Control-Max-Age', $maxAge); + } + $exposeHeaders = $this->getProperty('exposeHeaders', ''); + if ($this->debug) { + $exposeHeaders = implode(', ', array_filter([$exposeHeaders, 'X-Exception-Name, X-Exception-Message, X-Exception-File'])); + } + if ($exposeHeaders) { + $response = $response->withHeader('Access-Control-Expose-Headers', $exposeHeaders); + } + } else { + $response = null; + try { + $response = $next->handle($request); + } catch (\Throwable $e) { + $response = $this->responder->error(ErrorCode::ERROR_NOT_FOUND, $e->getMessage()); + if ($this->debug) { + $response = ResponseUtils::addExceptionHeaders($response, $e); + } + } + } + if ($origin) { + $allowCredentials = $this->getProperty('allowCredentials', 'true'); + if ($allowCredentials) { + $response = $response->withHeader('Access-Control-Allow-Credentials', $allowCredentials); + } + $response = $response->withHeader('Access-Control-Allow-Origin', $origin); + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/CustomizationMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + + class CustomizationMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + $tableName = RequestUtils::getPathSegment($request, 2); + $beforeHandler = $this->getProperty('beforeHandler', ''); + $environment = (object) array(); + if ($beforeHandler !== '') { + $result = call_user_func($beforeHandler, $operation, $tableName, $request, $environment); + $request = $result ?: $request; + } + $response = $next->handle($request); + $afterHandler = $this->getProperty('afterHandler', ''); + if ($afterHandler !== '') { + $result = call_user_func($afterHandler, $operation, $tableName, $response, $environment); + $response = $result ?: $response; + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\OrderingInfo; + use Tqdev\PhpCrudApi\RequestUtils; + + class DbAuthMiddleware extends Middleware + { + private $reflection; + private $db; + private $ordering; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection, GenericDB $db) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + $this->db = $db; + $this->ordering = new OrderingInfo(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $path = RequestUtils::getPathSegment($request, 1); + $method = $request->getMethod(); + if ($method == 'POST' && in_array($path, ['login', 'register', 'password'])) { + $body = $request->getParsedBody(); + $username = isset($body->username) ? $body->username : ''; + $password = isset($body->password) ? $body->password : ''; + $newPassword = isset($body->newPassword) ? $body->newPassword : ''; + $tableName = $this->getProperty('usersTable', 'users'); + $table = $this->reflection->getTable($tableName); + $usernameColumnName = $this->getProperty('usernameColumn', 'username'); + $usernameColumn = $table->getColumn($usernameColumnName); + $passwordColumnName = $this->getProperty('passwordColumn', 'password'); + $passwordLength = $this->getProperty('passwordLength', '12'); + $pkName = $table->getPk()->getName(); + $registerUser = $this->getProperty('registerUser', ''); + $condition = new ColumnCondition($usernameColumn, 'eq', $username); + $returnedColumns = $this->getProperty('returnedColumns', ''); + if (!$returnedColumns) { + $columnNames = $table->getColumnNames(); + } else { + $columnNames = array_map('trim', explode(',', $returnedColumns)); + $columnNames[] = $passwordColumnName; + $columnNames[] = $pkName; + } + $columnOrdering = $this->ordering->getDefaultColumnOrdering($table); + if ($path == 'register') { + if (!$registerUser) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (strlen($password) < $passwordLength) { + return $this->responder->error(ErrorCode::PASSWORD_TOO_SHORT, $passwordLength); + } + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + if (!empty($users)) { + return $this->responder->error(ErrorCode::USER_ALREADY_EXIST, $username); + } + $data = json_decode($registerUser, true); + $data = is_array($data) ? $data : []; + $data[$usernameColumnName] = $username; + $data[$passwordColumnName] = password_hash($password, PASSWORD_DEFAULT); + $this->db->createSingle($table, $data); + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if ($path == 'login') { + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + if (password_verify($password, $user[$passwordColumnName]) == 1) { + if (!headers_sent()) { + session_regenerate_id(true); + } + unset($user[$passwordColumnName]); + $_SESSION['user'] = $user; + return $this->responder->success($user); + } + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if ($path == 'password') { + if ($username != ($_SESSION['user'][$usernameColumnName] ?? '')) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (strlen($newPassword) < $passwordLength) { + return $this->responder->error(ErrorCode::PASSWORD_TOO_SHORT, $passwordLength); + } + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + if (password_verify($password, $user[$passwordColumnName]) == 1) { + if (!headers_sent()) { + session_regenerate_id(true); + } + $data = [$passwordColumnName => password_hash($newPassword, PASSWORD_DEFAULT)]; + $this->db->updateSingle($table, $data, $user[$pkName]); + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + } + if ($method == 'POST' && $path == 'logout') { + if (isset($_SESSION['user'])) { + $user = $_SESSION['user']; + unset($_SESSION['user']); + if (session_status() != PHP_SESSION_NONE) { + session_destroy(); + } + return $this->responder->success($user); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + if ($method == 'GET' && $path == 'me') { + if (isset($_SESSION['user'])) { + return $this->responder->success($_SESSION['user']); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + if (!isset($_SESSION['user']) || !$_SESSION['user']) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/FirewallMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + + class FirewallMiddleware extends Middleware + { + private function ipMatch(string $ip, string $cidr): bool + { + if (strpos($cidr, '/') !== false) { + list($subnet, $mask) = explode('/', trim($cidr)); + if ((ip2long($ip) & ~((1 << (32 - $mask)) - 1)) == ip2long($subnet)) { + return true; + } + } else { + if (ip2long($ip) == ip2long($cidr)) { + return true; + } + } + return false; + } + + private function isIpAllowed(string $ipAddress, string $allowedIpAddresses): bool + { + foreach (explode(',', $allowedIpAddresses) as $allowedIp) { + if ($this->ipMatch($ipAddress, $allowedIp)) { + return true; + } + } + return false; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $reverseProxy = $this->getProperty('reverseProxy', ''); + if ($reverseProxy) { + $ipAddress = array_pop(explode(',', $request->getHeader('X-Forwarded-For'))); + } elseif (isset($_SERVER['REMOTE_ADDR'])) { + $ipAddress = $_SERVER['REMOTE_ADDR']; + } else { + $ipAddress = '127.0.0.1'; + } + $allowedIpAddresses = $this->getProperty('allowedIpAddresses', ''); + if (!$this->isIpAllowed($ipAddress, $allowedIpAddresses)) { + $response = $this->responder->error(ErrorCode::TEMPORARY_OR_PERMANENTLY_BLOCKED, ''); + } else { + $response = $next->handle($request); + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/IpAddressMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + + class IpAddressMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function callHandler($record, string $operation, ReflectedTable $table) /*: object */ + { + $context = (array) $record; + $columnNames = $this->getProperty('columns', ''); + if ($columnNames) { + foreach (explode(',', $columnNames) as $columnName) { + if ($table->hasColumn($columnName)) { + if ($operation == 'create') { + $context[$columnName] = $_SERVER['REMOTE_ADDR']; + } else { + unset($context[$columnName]); + } + } + } + } + return (object) $context; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableNames = $this->getProperty('tables', ''); + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$tableNames || in_array($tableName, explode(',', $tableNames))) { + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as &$r) { + $r = $this->callHandler($r, $operation, $table); + } + } else { + $record = $this->callHandler($record, $operation, $table); + } + $request = $request->withParsedBody($record); + } + } + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/JoinLimitsMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + + class JoinLimitsMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + $params = RequestUtils::getParams($request); + if (in_array($operation, ['read', 'list']) && isset($params['join'])) { + $maxDepth = (int) $this->getProperty('depth', '3'); + $maxTables = (int) $this->getProperty('tables', '10'); + $maxRecords = (int) $this->getProperty('records', '1000'); + $tableCount = 0; + $joinPaths = array(); + for ($i = 0; $i < count($params['join']); $i++) { + $joinPath = array(); + $tables = explode(',', $params['join'][$i]); + for ($depth = 0; $depth < min($maxDepth, count($tables)); $depth++) { + array_push($joinPath, $tables[$depth]); + $tableCount += 1; + if ($tableCount == $maxTables) { + break; + } + } + array_push($joinPaths, implode(',', $joinPath)); + if ($tableCount == $maxTables) { + break; + } + } + $params['join'] = $joinPaths; + $request = RequestUtils::setParams($request, $params); + VariableStore::set("joinLimits.maxRecords", $maxRecords); + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class JwtAuthMiddleware extends Middleware + { + private function getVerifiedClaims(string $token, int $time, int $leeway, int $ttl, array $secrets, array $requirements): array + { + $algorithms = array( + 'HS256' => 'sha256', + 'HS384' => 'sha384', + 'HS512' => 'sha512', + 'RS256' => 'sha256', + 'RS384' => 'sha384', + 'RS512' => 'sha512', + ); + $token = explode('.', $token); + if (count($token) < 3) { + return array(); + } + $header = json_decode(base64_decode(strtr($token[0], '-_', '+/')), true); + $kid = 0; + if (isset($header['kid'])) { + $kid = $header['kid']; + } + if (!isset($secrets[$kid])) { + return array(); + } + $secret = $secrets[$kid]; + if ($header['typ'] != 'JWT') { + return array(); + } + $algorithm = $header['alg']; + if (!isset($algorithms[$algorithm])) { + return array(); + } + if (!empty($requirements['alg']) && !in_array($algorithm, $requirements['alg'])) { + return array(); + } + $hmac = $algorithms[$algorithm]; + $signature = base64_decode(strtr($token[2], '-_', '+/')); + $data = "$token[0].$token[1]"; + switch ($algorithm[0]) { + case 'H': + $hash = hash_hmac($hmac, $data, $secret, true); + $equals = hash_equals($hash, $signature); + if (!$equals) { + return array(); + } + break; + case 'R': + $equals = openssl_verify($data, $signature, $secret, $hmac) == 1; + if (!$equals) { + return array(); + } + break; + } + $claims = json_decode(base64_decode(strtr($token[1], '-_', '+/')), true); + if (!$claims) { + return array(); + } + foreach ($requirements as $field => $values) { + if (!empty($values)) { + if ($field != 'alg') { + if (!isset($claims[$field]) || !in_array($claims[$field], $values)) { + return array(); + } + } + } + } + if (isset($claims['nbf']) && $time + $leeway < $claims['nbf']) { + return array(); + } + if (isset($claims['iat']) && $time + $leeway < $claims['iat']) { + return array(); + } + if (isset($claims['exp']) && $time - $leeway > $claims['exp']) { + return array(); + } + if (isset($claims['iat']) && !isset($claims['exp'])) { + if ($time - $leeway > $claims['iat'] + $ttl) { + return array(); + } + } + return $claims; + } + + private function getClaims(string $token): array + { + $time = (int) $this->getProperty('time', time()); + $leeway = (int) $this->getProperty('leeway', '5'); + $ttl = (int) $this->getProperty('ttl', '30'); + $secrets = $this->getMapProperty('secrets', ''); + if (!$secrets) { + $secrets = [$this->getProperty('secret', '')]; + } + $requirements = array( + 'alg' => $this->getArrayProperty('algorithms', ''), + 'aud' => $this->getArrayProperty('audiences', ''), + 'iss' => $this->getArrayProperty('issuers', ''), + ); + return $this->getVerifiedClaims($token, $time, $leeway, $ttl, $secrets, $requirements); + } + + private function getAuthorizationToken(ServerRequestInterface $request): string + { + $headerName = $this->getProperty('header', 'X-Authorization'); + $headerValue = RequestUtils::getHeader($request, $headerName); + $parts = explode(' ', trim($headerValue), 2); + if (count($parts) != 2) { + return ''; + } + if ($parts[0] != 'Bearer') { + return ''; + } + return $parts[1]; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $token = $this->getAuthorizationToken($request); + if ($token) { + $claims = $this->getClaims($token); + $_SESSION['claims'] = $claims; + if (empty($claims)) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, 'JWT'); + } + if (!headers_sent()) { + session_regenerate_id(); + } + } + if (empty($_SESSION['claims'])) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/MultiTenancyMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\Condition\Condition; + use Tqdev\PhpCrudApi\Record\Condition\NoCondition; + use Tqdev\PhpCrudApi\RequestUtils; + + class MultiTenancyMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function getCondition(string $tableName, array $pairs): Condition + { + $condition = new NoCondition(); + $table = $this->reflection->getTable($tableName); + foreach ($pairs as $k => $v) { + $condition = $condition->_and(new ColumnCondition($table->getColumn($k), 'eq', $v)); + } + return $condition; + } + + private function getPairs($handler, string $operation, string $tableName): array + { + $result = array(); + $pairs = call_user_func($handler, $operation, $tableName) ?: []; + $table = $this->reflection->getTable($tableName); + foreach ($pairs as $k => $v) { + if ($table->hasColumn($k)) { + $result[$k] = $v; + } + } + return $result; + } + + private function handleRecord(ServerRequestInterface $request, string $operation, array $pairs): ServerRequestInterface + { + $record = $request->getParsedBody(); + if ($record === null) { + return $request; + } + $multi = is_array($record); + $records = $multi ? $record : [$record]; + foreach ($records as &$record) { + foreach ($pairs as $column => $value) { + if ($operation == 'create') { + $record->$column = $value; + } else { + if (isset($record->$column)) { + unset($record->$column); + } + } + } + } + return $request->withParsedBody($multi ? $records : $records[0]); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $path = RequestUtils::getPathSegment($request, 1); + if ($path == 'records') { + $operation = RequestUtils::getOperation($request); + $tableNames = RequestUtils::getTableNames($request, $this->reflection); + foreach ($tableNames as $i => $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $pairs = $this->getPairs($handler, $operation, $tableName); + if ($i == 0) { + if (in_array($operation, ['create', 'update', 'increment'])) { + $request = $this->handleRecord($request, $operation, $pairs); + } + } + $condition = $this->getCondition($tableName, $pairs); + VariableStore::set("multiTenancy.conditions.$tableName", $condition); + } + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/PageLimitsMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class PageLimitsMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if ($operation == 'list') { + $params = RequestUtils::getParams($request); + $maxPage = (int) $this->getProperty('pages', '100'); + if (isset($params['page']) && $params['page'] && $maxPage > 0) { + if (strpos($params['page'][0], ',') === false) { + $page = $params['page'][0]; + } else { + list($page, $size) = explode(',', $params['page'][0], 2); + } + if ($page > $maxPage) { + return $this->responder->error(ErrorCode::PAGINATION_FORBIDDEN, ''); + } + } + $maxSize = (int) $this->getProperty('records', '1000'); + if (!isset($params['size']) || !$params['size'] && $maxSize > 0) { + $params['size'] = array($maxSize); + } else { + $params['size'] = array(min($params['size'][0], $maxSize)); + } + $request = RequestUtils::setParams($request, $params); + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/ReconnectMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + + class ReconnectMiddleware extends Middleware + { + private $reflection; + private $db; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection, GenericDB $db) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + $this->db = $db; + } + + private function getDriver(): string + { + $driverHandler = $this->getProperty('driverHandler', ''); + if ($driverHandler) { + return call_user_func($driverHandler); + } + return ''; + } + + private function getAddress(): string + { + $addressHandler = $this->getProperty('addressHandler', ''); + if ($addressHandler) { + return call_user_func($addressHandler); + } + return ''; + } + + private function getPort(): int + { + $portHandler = $this->getProperty('portHandler', ''); + if ($portHandler) { + return call_user_func($portHandler); + } + return 0; + } + + private function getDatabase(): string + { + $databaseHandler = $this->getProperty('databaseHandler', ''); + if ($databaseHandler) { + return call_user_func($databaseHandler); + } + return ''; + } + + private function getTables(): array + { + $tablesHandler = $this->getProperty('tablesHandler', ''); + if ($tablesHandler) { + return call_user_func($tablesHandler); + } + return []; + } + + private function getUsername(): string + { + $usernameHandler = $this->getProperty('usernameHandler', ''); + if ($usernameHandler) { + return call_user_func($usernameHandler); + } + return ''; + } + + private function getPassword(): string + { + $passwordHandler = $this->getProperty('passwordHandler', ''); + if ($passwordHandler) { + return call_user_func($passwordHandler); + } + return ''; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $driver = $this->getDriver(); + $address = $this->getAddress(); + $port = $this->getPort(); + $database = $this->getDatabase(); + $tables = $this->getTables(); + $username = $this->getUsername(); + $password = $this->getPassword(); + if ($driver || $address || $port || $database || $tables || $username || $password) { + $this->db->reconstruct($driver, $address, $port, $database, $tables, $username, $password); + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + + class SanitationMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function callHandler($handler, $record, string $operation, ReflectedTable $table) /*: object */ + { + $context = (array) $record; + $tableName = $table->getName(); + foreach ($context as $columnName => &$value) { + if ($table->hasColumn($columnName)) { + $column = $table->getColumn($columnName); + $value = call_user_func($handler, $operation, $tableName, $column->serialize(), $value); + $value = $this->sanitizeType($table, $column, $value); + } + } + return (object) $context; + } + + private function sanitizeType(ReflectedTable $table, ReflectedColumn $column, $value) + { + $tables = $this->getArrayProperty('tables', 'all'); + $types = $this->getArrayProperty('types', 'all'); + if ( + (in_array('all', $tables) || in_array($table->getName(), $tables)) && + (in_array('all', $types) || in_array($column->getType(), $types)) + ) { + if (is_null($value)) { + return $value; + } + if (is_string($value)) { + $newValue = null; + switch ($column->getType()) { + case 'integer': + case 'bigint': + $newValue = filter_var(trim($value), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + break; + case 'decimal': + $newValue = filter_var(trim($value), FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + if (is_float($newValue)) { + $newValue = number_format($newValue, $column->getScale(), '.', ''); + } + break; + case 'float': + case 'double': + $newValue = filter_var(trim($value), FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + break; + case 'boolean': + $newValue = filter_var(trim($value), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + break; + case 'date': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('Y-m-d', $time); + } + break; + case 'time': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('H:i:s', $time); + } + break; + case 'timestamp': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('Y-m-d H:i:s', $time); + } + break; + case 'blob': + case 'varbinary': + // allow base64url format + $newValue = strtr(trim($value), '-_', '+/'); + break; + case 'clob': + case 'varchar': + $newValue = $value; + break; + case 'geometry': + $newValue = trim($value); + break; + } + if (!is_null($newValue)) { + $value = $newValue; + } + } else { + switch ($column->getType()) { + case 'integer': + case 'bigint': + if (is_float($value)) { + $value = (int) round($value); + } + break; + case 'decimal': + if (is_float($value) || is_int($value)) { + $value = number_format((float) $value, $column->getScale(), '.', ''); + } + break; + } + } + // post process + } + return $value; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableName = RequestUtils::getPathSegment($request, 2); + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as &$r) { + $r = $this->callHandler($handler, $r, $operation, $table); + } + } else { + $record = $this->callHandler($handler, $record, $operation, $table); + } + $request = $request->withParsedBody($record); + } + } + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/SslRedirectMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\ResponseFactory; + + class SslRedirectMiddleware extends Middleware + { + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + if ($scheme == 'http') { + $uri = $request->getUri(); + $uri = $uri->withScheme('https'); + $response = ResponseFactory::fromStatus(301); + $response = $response->withHeader('Location', $uri->__toString()); + } else { + $response = $next->handle($request); + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\RequestUtils; + + class ValidationMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function callHandler($handler, $record, string $operation, ReflectedTable $table) /*: ResponseInterface?*/ + { + $context = (array) $record; + $details = array(); + $tableName = $table->getName(); + foreach ($context as $columnName => $value) { + if ($table->hasColumn($columnName)) { + $column = $table->getColumn($columnName); + $valid = call_user_func($handler, $operation, $tableName, $column->serialize(), $value, $context); + if ($valid === true || $valid === '') { + $valid = $this->validateType($table, $column, $value); + } + if ($valid !== true && $valid !== '') { + $details[$columnName] = $valid; + } + } + } + if (count($details) > 0) { + return $this->responder->error(ErrorCode::INPUT_VALIDATION_FAILED, $tableName, $details); + } + return null; + } + + private function validateType(ReflectedTable $table, ReflectedColumn $column, $value) + { + $tables = $this->getArrayProperty('tables', 'all'); + $types = $this->getArrayProperty('types', 'all'); + if ( + (in_array('all', $tables) || in_array($table->getName(), $tables)) && + (in_array('all', $types) || in_array($column->getType(), $types)) + ) { + if (is_null($value)) { + return ($column->getNullable() ? true : "cannot be null"); + } + if (is_string($value)) { + // check for whitespace + switch ($column->getType()) { + case 'varchar': + case 'clob': + break; + default: + if (strlen(trim($value)) != strlen($value)) { + return 'illegal whitespace'; + } + break; + } + // try to parse + switch ($column->getType()) { + case 'integer': + case 'bigint': + if ( + filter_var($value, FILTER_SANITIZE_NUMBER_INT) !== $value || + filter_var($value, FILTER_VALIDATE_INT) === false + ) { + return 'invalid integer'; + } + break; + case 'decimal': + if (strpos($value, '.') !== false) { + list($whole, $decimals) = explode('.', ltrim($value, '-'), 2); + } else { + list($whole, $decimals) = array(ltrim($value, '-'), ''); + } + if (strlen($whole) > 0 && !ctype_digit($whole)) { + return 'invalid decimal'; + } + if (strlen($decimals) > 0 && !ctype_digit($decimals)) { + return 'invalid decimal'; + } + if (strlen($whole) > $column->getPrecision() - $column->getScale()) { + return 'decimal too large'; + } + if (strlen($decimals) > $column->getScale()) { + return 'decimal too precise'; + } + break; + case 'float': + case 'double': + if ( + filter_var($value, FILTER_SANITIZE_NUMBER_FLOAT) !== $value || + filter_var($value, FILTER_VALIDATE_FLOAT) === false + ) { + return 'invalid float'; + } + break; + case 'boolean': + if (!in_array(strtolower($value), array('true', 'false'))) { + return 'invalid boolean'; + } + break; + case 'date': + if (date_create_from_format('Y-m-d', $value) === false) { + return 'invalid date'; + } + break; + case 'time': + if (date_create_from_format('H:i:s', $value) === false) { + return 'invalid time'; + } + break; + case 'timestamp': + if (date_create_from_format('Y-m-d H:i:s', $value) === false) { + return 'invalid timestamp'; + } + break; + case 'clob': + case 'varchar': + if ($column->hasLength() && mb_strlen($value, 'UTF-8') > $column->getLength()) { + return 'string too long'; + } + break; + case 'blob': + case 'varbinary': + if (base64_decode($value, true) === false) { + return 'invalid base64'; + } + if ($column->hasLength() && strlen(base64_decode($value)) > $column->getLength()) { + return 'string too long'; + } + break; + case 'geometry': + // no checks yet + break; + } + } else { // check non-string types + switch ($column->getType()) { + case 'integer': + case 'bigint': + if (!is_int($value)) { + return 'invalid integer'; + } + break; + case 'float': + case 'double': + if (!is_float($value) && !is_int($value)) { + return 'invalid float'; + } + break; + case 'boolean': + if (!is_bool($value) && ($value !== 0) && ($value !== 1)) { + return 'invalid boolean'; + } + break; + default: + return 'invalid ' . $column->getType(); + } + } + // extra checks + switch ($column->getType()) { + case 'integer': // 4 byte signed + $value = filter_var($value, FILTER_VALIDATE_INT); + if ($value > 2147483647 || $value < -2147483648) { + return 'invalid integer'; + } + break; + } + } + return (true); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableName = RequestUtils::getPathSegment($request, 2); + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as $r) { + $response = $this->callHandler($handler, $r, $operation, $table); + if ($response !== null) { + return $response; + } + } + } else { + $response = $this->callHandler($handler, $record, $operation, $table); + if ($response !== null) { + return $response; + } + } + } + } + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/XmlMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Middleware\Router\Router; + use Tqdev\PhpCrudApi\RequestUtils; + use Tqdev\PhpCrudApi\ResponseFactory; + + class XmlMiddleware extends Middleware + { + private $reflection; + + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } + + private function json2xml($json, $types = 'null,boolean,number,string,object,array') + { + $a = json_decode($json); + $d = new \DOMDocument(); + $c = $d->createElement("root"); + $d->appendChild($c); + $t = function ($v) { + $type = gettype($v); + switch ($type) { + case 'integer': + return 'number'; + case 'double': + return 'number'; + default: + return strtolower($type); + } + }; + $ts = explode(',', $types); + $f = function ($f, $c, $a, $s = false) use ($t, $d, $ts) { + if (in_array($t($a), $ts)) { + $c->setAttribute('type', $t($a)); + } + if ($t($a) != 'array' && $t($a) != 'object') { + if ($t($a) == 'boolean') { + $c->appendChild($d->createTextNode($a ? 'true' : 'false')); + } else { + $c->appendChild($d->createTextNode($a)); + } + } else { + foreach ($a as $k => $v) { + if ($k == '__type' && $t($a) == 'object') { + $c->setAttribute('__type', $v); + } else { + if ($t($v) == 'object') { + $ch = $c->appendChild($d->createElementNS(null, $s ? 'item' : $k)); + $f($f, $ch, $v); + } else if ($t($v) == 'array') { + $ch = $c->appendChild($d->createElementNS(null, $s ? 'item' : $k)); + $f($f, $ch, $v, true); + } else { + $va = $d->createElementNS(null, $s ? 'item' : $k); + if ($t($v) == 'boolean') { + $va->appendChild($d->createTextNode($v ? 'true' : 'false')); + } else { + $va->appendChild($d->createTextNode($v)); + } + $ch = $c->appendChild($va); + if (in_array($t($v), $ts)) { + $ch->setAttribute('type', $t($v)); + } + } + } + } + } + }; + $f($f, $c, $a, $t($a) == 'array'); + return $d->saveXML($d->documentElement); + } + + private function xml2json($xml) + { + $a = @dom_import_simplexml(simplexml_load_string($xml)); + if (!$a) { + return null; + } + $t = function ($v) { + $t = $v->getAttribute('type'); + $txt = $v->firstChild->nodeType == XML_TEXT_NODE; + return $t ?: ($txt ? 'string' : 'object'); + }; + $f = function ($f, $a) use ($t) { + $c = null; + if ($t($a) == 'null') { + $c = null; + } else if ($t($a) == 'boolean') { + $b = substr(strtolower($a->textContent), 0, 1); + $c = in_array($b, array('1', 't')); + } else if ($t($a) == 'number') { + $c = $a->textContent + 0; + } else if ($t($a) == 'string') { + $c = $a->textContent; + } else if ($t($a) == 'object') { + $c = array(); + if ($a->getAttribute('__type')) { + $c['__type'] = $a->getAttribute('__type'); + } + for ($i = 0; $i < $a->childNodes->length; $i++) { + $v = $a->childNodes[$i]; + $c[$v->nodeName] = $f($f, $v); + } + $c = (object) $c; + } else if ($t($a) == 'array') { + $c = array(); + for ($i = 0; $i < $a->childNodes->length; $i++) { + $v = $a->childNodes[$i]; + $c[$i] = $f($f, $v); + } + } + return $c; + }; + $c = $f($f, $a); + return json_encode($c); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + parse_str($request->getUri()->getQuery(), $params); + $isXml = isset($params['format']) && $params['format'] == 'xml'; + if ($isXml) { + $body = $request->getBody()->getContents(); + if ($body) { + $json = $this->xml2json($body); + $request = $request->withParsedBody(json_decode($json)); + } + } + $response = $next->handle($request); + if ($isXml) { + $body = $response->getBody()->getContents(); + if ($body) { + $types = implode(',', $this->getArrayProperty('types', 'null,array')); + if ($types == '' || $types == 'all') { + $xml = $this->json2xml($body); + } else { + $xml = $this->json2xml($body, $types); + } + $response = ResponseFactory::fromXml(ResponseFactory::OK, $xml); + } + } + return $response; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Middleware/XsrfMiddleware.php +namespace Tqdev\PhpCrudApi\Middleware { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Controller\Responder; + use Tqdev\PhpCrudApi\Middleware\Base\Middleware; + use Tqdev\PhpCrudApi\Record\ErrorCode; + + class XsrfMiddleware extends Middleware + { + private function getToken(): string + { + $cookieName = $this->getProperty('cookieName', 'XSRF-TOKEN'); + if (isset($_COOKIE[$cookieName])) { + $token = $_COOKIE[$cookieName]; + } else { + $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on'; + $token = bin2hex(random_bytes(8)); + if (!headers_sent()) { + setcookie($cookieName, $token, 0, '', '', $secure); + } + } + return $token; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $token = $this->getToken(); + $method = $request->getMethod(); + $excludeMethods = $this->getArrayProperty('excludeMethods', 'OPTIONS,GET'); + if (!in_array($method, $excludeMethods)) { + $headerName = $this->getProperty('headerName', 'X-XSRF-TOKEN'); + if ($token != $request->getHeader($headerName)) { + return $this->responder->error(ErrorCode::BAD_OR_MISSING_XSRF_TOKEN, ''); + } + } + return $next->handle($request); + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiBuilder.php +namespace Tqdev\PhpCrudApi\OpenApi { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\OpenApi\OpenApiDefinition; + + class OpenApiBuilder + { + private $openapi; + private $records; + private $columns; + private $builders; + + public function __construct(ReflectionService $reflection, array $base, array $controllers, array $builders) + { + $this->openapi = new OpenApiDefinition($base); + $this->records = in_array('records', $controllers) ? new OpenApiRecordsBuilder($this->openapi, $reflection) : null; + $this->columns = in_array('columns', $controllers) ? new OpenApiColumnsBuilder($this->openapi) : null; + $this->builders = array(); + foreach ($builders as $className) { + $this->builders[] = new $className($this->openapi, $reflection); + } + } + + private function getServerUrl(): string + { + $protocol = @$_SERVER['HTTP_X_FORWARDED_PROTO'] ?: @$_SERVER['REQUEST_SCHEME'] ?: ((isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] == "on") ? "https" : "http"); + $port = @intval($_SERVER['HTTP_X_FORWARDED_PORT']) ?: @intval($_SERVER["SERVER_PORT"]) ?: (($protocol === 'https') ? 443 : 80); + $host = @explode(":", $_SERVER['HTTP_HOST'])[0] ?: @$_SERVER['SERVER_NAME'] ?: @$_SERVER['SERVER_ADDR']; + $port = ($protocol === 'https' && $port === 443) || ($protocol === 'http' && $port === 80) ? '' : ':' . $port; + $path = @trim(substr($_SERVER['REQUEST_URI'], 0, strpos($_SERVER['REQUEST_URI'], '/openapi')), '/'); + return sprintf('%s://%s%s/%s', $protocol, $host, $port, $path); + } + + public function build(): OpenApiDefinition + { + $this->openapi->set("openapi", "3.0.0"); + if (!$this->openapi->has("servers") && isset($_SERVER['REQUEST_URI'])) { + $this->openapi->set("servers|0|url", $this->getServerUrl()); + } + if ($this->records) { + $this->records->build(); + } + if ($this->columns) { + $this->columns->build(); + } + foreach ($this->builders as $builder) { + $builder->build(); + } + return $this->openapi; + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiColumnsBuilder.php +namespace Tqdev\PhpCrudApi\OpenApi { + + use Tqdev\PhpCrudApi\OpenApi\OpenApiDefinition; + + class OpenApiColumnsBuilder + { + private $openapi; + private $operations = [ + 'database' => [ + 'read' => 'get', + ], + 'table' => [ + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', //rename + 'delete' => 'delete', + ], + 'column' => [ + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', + 'delete' => 'delete', + ] + ]; + + public function __construct(OpenApiDefinition $openapi) + { + $this->openapi = $openapi; + } + + public function build() /*: void*/ + { + $this->setPaths(); + $this->openapi->set("components|responses|boolSuccess|description", "boolean indicating success or failure"); + $this->openapi->set("components|responses|boolSuccess|content|application/json|schema|type", "boolean"); + $this->setComponentSchema(); + $this->setComponentResponse(); + $this->setComponentRequestBody(); + $this->setComponentParameters(); + foreach (array_keys($this->operations) as $index => $type) { + $this->setTag($index, $type); + } + } + + private function setPaths() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach ($this->operations[$type] as $operation => $method) { + $parameters = []; + switch ($type) { + case 'database': + $path = '/columns'; + break; + case 'table': + $path = $operation == 'create' ? '/columns' : '/columns/{table}'; + break; + case 'column': + $path = $operation == 'create' ? '/columns/{table}' : '/columns/{table}/{column}'; + break; + } + if (strpos($path, '{table}')) { + $parameters[] = 'table'; + } + if (strpos($path, '{column}')) { + $parameters[] = 'column'; + } + foreach ($parameters as $p => $parameter) { + $this->openapi->set("paths|$path|$method|parameters|$p|\$ref", "#/components/parameters/$parameter"); + } + $operationType = $operation . ucfirst($type); + if (in_array($operation, ['create', 'update'])) { + $this->openapi->set("paths|$path|$method|requestBody|\$ref", "#/components/requestBodies/$operationType"); + } + $this->openapi->set("paths|$path|$method|tags|0", "$type"); + $this->openapi->set("paths|$path|$method|operationId", "$operation" . "_" . "$type"); + if ($operationType == 'updateTable') { + $this->openapi->set("paths|$path|$method|description", "rename table"); + } else { + $this->openapi->set("paths|$path|$method|description", "$operation $type"); + } + switch ($operation) { + case 'read': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operationType"); + break; + case 'create': + case 'update': + case 'delete': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/boolSuccess"); + break; + } + } + } + } + + private function setComponentSchema() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if ($operation == 'delete') { + continue; + } + $operationType = $operation . ucfirst($type); + $prefix = "components|schemas|$operationType"; + $this->openapi->set("$prefix|type", "object"); + switch ($type) { + case 'database': + $this->openapi->set("$prefix|properties|tables|type", 'array'); + $this->openapi->set("$prefix|properties|tables|items|\$ref", "#/components/schemas/readTable"); + break; + case 'table': + if ($operation == 'update') { + $this->openapi->set("$prefix|required", ['name']); + $this->openapi->set("$prefix|properties|name|type", 'string'); + } else { + $this->openapi->set("$prefix|properties|name|type", 'string'); + if ($operation == 'read') { + $this->openapi->set("$prefix|properties|type|type", 'string'); + } + $this->openapi->set("$prefix|properties|columns|type", 'array'); + $this->openapi->set("$prefix|properties|columns|items|\$ref", "#/components/schemas/readColumn"); + } + break; + case 'column': + $this->openapi->set("$prefix|required", ['name', 'type']); + $this->openapi->set("$prefix|properties|name|type", 'string'); + $this->openapi->set("$prefix|properties|type|type", 'string'); + $this->openapi->set("$prefix|properties|length|type", 'integer'); + $this->openapi->set("$prefix|properties|length|format", "int64"); + $this->openapi->set("$prefix|properties|precision|type", 'integer'); + $this->openapi->set("$prefix|properties|precision|format", "int64"); + $this->openapi->set("$prefix|properties|scale|type", 'integer'); + $this->openapi->set("$prefix|properties|scale|format", "int64"); + $this->openapi->set("$prefix|properties|nullable|type", 'boolean'); + $this->openapi->set("$prefix|properties|pk|type", 'boolean'); + $this->openapi->set("$prefix|properties|fk|type", 'string'); + break; + } + } + } + } + + private function setComponentResponse() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if ($operation != 'read') { + continue; + } + $operationType = $operation . ucfirst($type); + $this->openapi->set("components|responses|$operationType|description", "single $type record"); + $this->openapi->set("components|responses|$operationType|content|application/json|schema|\$ref", "#/components/schemas/$operationType"); + } + } + } + + private function setComponentRequestBody() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if (!in_array($operation, ['create', 'update'])) { + continue; + } + $operationType = $operation . ucfirst($type); + $this->openapi->set("components|requestBodies|$operationType|description", "single $type record"); + $this->openapi->set("components|requestBodies|$operationType|content|application/json|schema|\$ref", "#/components/schemas/$operationType"); + } + } + } + + private function setComponentParameters() /*: void*/ + { + $this->openapi->set("components|parameters|table|name", "table"); + $this->openapi->set("components|parameters|table|in", "path"); + $this->openapi->set("components|parameters|table|schema|type", "string"); + $this->openapi->set("components|parameters|table|description", "table name"); + $this->openapi->set("components|parameters|table|required", true); + + $this->openapi->set("components|parameters|column|name", "column"); + $this->openapi->set("components|parameters|column|in", "path"); + $this->openapi->set("components|parameters|column|schema|type", "string"); + $this->openapi->set("components|parameters|column|description", "column name"); + $this->openapi->set("components|parameters|column|required", true); + } + + private function setTag(int $index, string $type) /*: void*/ + { + $this->openapi->set("tags|$index|name", "$type"); + $this->openapi->set("tags|$index|description", "$type operations"); + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiDefinition.php +namespace Tqdev\PhpCrudApi\OpenApi { + + class OpenApiDefinition implements \JsonSerializable + { + private $root; + + public function __construct(array $base) + { + $this->root = $base; + } + + public function set(string $path, $value) /*: void*/ + { + $parts = explode('|', trim($path, '|')); + $current = &$this->root; + while (count($parts) > 0) { + $part = array_shift($parts); + if (!isset($current[$part])) { + $current[$part] = []; + } + $current = &$current[$part]; + } + $current = $value; + } + + public function has(string $path): bool + { + $parts = explode('|', trim($path, '|')); + $current = &$this->root; + while (count($parts) > 0) { + $part = array_shift($parts); + if (!isset($current[$part])) { + return false; + } + $current = &$current[$part]; + } + return true; + } + + public function jsonSerialize() + { + return $this->root; + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiRecordsBuilder.php +namespace Tqdev\PhpCrudApi\OpenApi { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\OpenApi\OpenApiDefinition; + + class OpenApiRecordsBuilder + { + private $openapi; + private $reflection; + private $operations = [ + 'list' => 'get', + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', + 'delete' => 'delete', + 'increment' => 'patch', + ]; + private $types = [ + 'integer' => ['type' => 'integer', 'format' => 'int32'], + 'bigint' => ['type' => 'integer', 'format' => 'int64'], + 'varchar' => ['type' => 'string'], + 'clob' => ['type' => 'string', 'format' => 'large-string'], //custom format + 'varbinary' => ['type' => 'string', 'format' => 'byte'], + 'blob' => ['type' => 'string', 'format' => 'large-byte'], //custom format + 'decimal' => ['type' => 'string', 'format' => 'decimal'], //custom format + 'float' => ['type' => 'number', 'format' => 'float'], + 'double' => ['type' => 'number', 'format' => 'double'], + 'date' => ['type' => 'string', 'format' => 'date'], + 'time' => ['type' => 'string', 'format' => 'time'], //custom format + 'timestamp' => ['type' => 'string', 'format' => 'date-time'], + 'geometry' => ['type' => 'string', 'format' => 'geometry'], //custom format + 'boolean' => ['type' => 'boolean'], + ]; + + public function __construct(OpenApiDefinition $openapi, ReflectionService $reflection) + { + $this->openapi = $openapi; + $this->reflection = $reflection; + } + + private function getAllTableReferences(): array + { + $tableReferences = array(); + foreach ($this->reflection->getTableNames() as $tableName) { + $table = $this->reflection->getTable($tableName); + foreach ($table->getColumnNames() as $columnName) { + $column = $table->getColumn($columnName); + $referencedTableName = $column->getFk(); + if ($referencedTableName) { + if (!isset($tableReferences[$referencedTableName])) { + $tableReferences[$referencedTableName] = array(); + } + $tableReferences[$referencedTableName][] = "$tableName.$columnName"; + } + } + } + return $tableReferences; + } + + public function build() /*: void*/ + { + $tableNames = $this->reflection->getTableNames(); + foreach ($tableNames as $tableName) { + $this->setPath($tableName); + } + $this->openapi->set("components|responses|pk_integer|description", "inserted primary key value (integer)"); + $this->openapi->set("components|responses|pk_integer|content|application/json|schema|type", "integer"); + $this->openapi->set("components|responses|pk_integer|content|application/json|schema|format", "int64"); + $this->openapi->set("components|responses|pk_string|description", "inserted primary key value (string)"); + $this->openapi->set("components|responses|pk_string|content|application/json|schema|type", "string"); + $this->openapi->set("components|responses|pk_string|content|application/json|schema|format", "uuid"); + $this->openapi->set("components|responses|rows_affected|description", "number of rows affected (integer)"); + $this->openapi->set("components|responses|rows_affected|content|application/json|schema|type", "integer"); + $this->openapi->set("components|responses|rows_affected|content|application/json|schema|format", "int64"); + $tableReferences = $this->getAllTableReferences(); + foreach ($tableNames as $tableName) { + $references = isset($tableReferences[$tableName]) ? $tableReferences[$tableName] : array(); + $this->setComponentSchema($tableName, $references); + $this->setComponentResponse($tableName); + $this->setComponentRequestBody($tableName); + } + $this->setComponentParameters(); + foreach ($tableNames as $index => $tableName) { + $this->setTag($index, $tableName); + } + } + + private function isOperationOnTableAllowed(string $operation, string $tableName): bool + { + $tableHandler = VariableStore::get('authorization.tableHandler'); + if (!$tableHandler) { + return true; + } + return (bool) call_user_func($tableHandler, $operation, $tableName); + } + + private function isOperationOnColumnAllowed(string $operation, string $tableName, string $columnName): bool + { + $columnHandler = VariableStore::get('authorization.columnHandler'); + if (!$columnHandler) { + return true; + } + return (bool) call_user_func($columnHandler, $operation, $tableName, $columnName); + } + + private function setPath(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach ($this->operations as $operation => $method) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type != 'table' && $operation != 'list') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + $parameters = []; + if (in_array($operation, ['list', 'create'])) { + $path = sprintf('/records/%s', $tableName); + if ($operation == 'list') { + $parameters = ['filter', 'include', 'exclude', 'order', 'size', 'page', 'join']; + } + } else { + $path = sprintf('/records/%s/{id}', $tableName); + if ($operation == 'read') { + $parameters = ['pk', 'include', 'exclude', 'join']; + } else { + $parameters = ['pk']; + } + } + foreach ($parameters as $p => $parameter) { + $this->openapi->set("paths|$path|$method|parameters|$p|\$ref", "#/components/parameters/$parameter"); + } + if (in_array($operation, ['create', 'update', 'increment'])) { + $this->openapi->set("paths|$path|$method|requestBody|\$ref", "#/components/requestBodies/$operation-" . rawurlencode($tableName)); + } + $this->openapi->set("paths|$path|$method|tags|0", "$tableName"); + $this->openapi->set("paths|$path|$method|operationId", "$operation" . "_" . "$tableName"); + $this->openapi->set("paths|$path|$method|description", "$operation $tableName"); + switch ($operation) { + case 'list': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operation-" . rawurlencode($tableName)); + break; + case 'create': + if ($pk->getType() == 'integer') { + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/pk_integer"); + } else { + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/pk_string"); + } + break; + case 'read': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operation-" . rawurlencode($tableName)); + break; + case 'update': + case 'delete': + case 'increment': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/rows_affected"); + break; + } + } + } + + private function getPattern(ReflectedColumn $column): string + { + switch ($column->getType()) { + case 'integer': + $n = strlen(pow(2, 31)); + return '^-?[0-9]{1,' . $n . '}$'; + case 'bigint': + $n = strlen(pow(2, 63)); + return '^-?[0-9]{1,' . $n . '}$'; + case 'varchar': + $l = $column->getLength(); + return '^.{0,' . $l . '}$'; + case 'clob': + return '^.*$'; + case 'varbinary': + $l = $column->getLength(); + $b = (int) 4 * ceil($l / 3); + return '^[A-Za-z0-9+/]{0,' . $b . '}=*$'; + case 'blob': + return '^[A-Za-z0-9+/]*=*$'; + case 'decimal': + $p = $column->getPrecision(); + $s = $column->getScale(); + return '^-?[0-9]{1,' . ($p - $s) . '}(\.[0-9]{1,' . $s . '})?$'; + case 'float': + return '^-?[0-9]+(\.[0-9]+)?([eE]-?[0-9]+)?$'; + case 'double': + return '^-?[0-9]+(\.[0-9]+)?([eE]-?[0-9]+)?$'; + case 'date': + return '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; + case 'time': + return '^[0-9]{2}:[0-9]{2}:[0-9]{2}$'; + case 'timestamp': + return '^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$'; + return ''; + case 'geometry': + return '^(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON)\s*\(.*$'; + case 'boolean': + return '^(true|false)$'; + } + return ''; + } + + private function setComponentSchema(string $tableName, array $references) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach ($this->operations as $operation => $method) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type == 'view' && !in_array($operation, array('read', 'list'))) { + continue; + } + if ($type == 'view' && !$pkName && $operation == 'read') { + continue; + } + if ($operation == 'delete') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + if ($operation == 'list') { + $this->openapi->set("components|schemas|$operation-$tableName|type", "object"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|results|type", "integer"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|results|format", "int64"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|records|type", "array"); + $prefix = "components|schemas|$operation-$tableName|properties|records|items"; + } else { + $prefix = "components|schemas|$operation-$tableName"; + } + $this->openapi->set("$prefix|type", "object"); + foreach ($table->getColumnNames() as $columnName) { + if (!$this->isOperationOnColumnAllowed($operation, $tableName, $columnName)) { + continue; + } + $column = $table->getColumn($columnName); + $properties = $this->types[$column->getType()]; + $properties['maxLength'] = $column->hasLength() ? $column->getLength() : 0; + $properties['nullable'] = $column->getNullable(); + $properties['pattern'] = $this->getPattern($column); + foreach ($properties as $key => $value) { + if ($value) { + $this->openapi->set("$prefix|properties|$columnName|$key", $value); + } + } + if ($column->getPk()) { + $this->openapi->set("$prefix|properties|$columnName|x-primary-key", true); + $this->openapi->set("$prefix|properties|$columnName|x-referenced", $references); + } + $fk = $column->getFk(); + if ($fk) { + $this->openapi->set("$prefix|properties|$columnName|x-references", $fk); + } + } + } + } + + private function setComponentResponse(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach (['list', 'read'] as $operation) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type != 'table' && $operation != 'list') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + if ($operation == 'list') { + $this->openapi->set("components|responses|$operation-$tableName|description", "list of $tableName records"); + } else { + $this->openapi->set("components|responses|$operation-$tableName|description", "single $tableName record"); + } + $this->openapi->set("components|responses|$operation-$tableName|content|application/json|schema|\$ref", "#/components/schemas/$operation-" . rawurlencode($tableName)); + } + } + + private function setComponentRequestBody(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + if ($pkName && $type == 'table') { + foreach (['create', 'update', 'increment'] as $operation) { + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + $this->openapi->set("components|requestBodies|$operation-$tableName|description", "single $tableName record"); + $this->openapi->set("components|requestBodies|$operation-$tableName|content|application/json|schema|\$ref", "#/components/schemas/$operation-" . rawurlencode($tableName)); + } + } + } + + private function setComponentParameters() /*: void*/ + { + $this->openapi->set("components|parameters|pk|name", "id"); + $this->openapi->set("components|parameters|pk|in", "path"); + $this->openapi->set("components|parameters|pk|schema|type", "string"); + $this->openapi->set("components|parameters|pk|description", "primary key value"); + $this->openapi->set("components|parameters|pk|required", true); + + $this->openapi->set("components|parameters|filter|name", "filter"); + $this->openapi->set("components|parameters|filter|in", "query"); + $this->openapi->set("components|parameters|filter|schema|type", "array"); + $this->openapi->set("components|parameters|filter|schema|items|type", "string"); + $this->openapi->set("components|parameters|filter|description", "Filters to be applied. Each filter consists of a column, an operator and a value (comma separated). Example: id,eq,1"); + $this->openapi->set("components|parameters|filter|required", false); + + $this->openapi->set("components|parameters|include|name", "include"); + $this->openapi->set("components|parameters|include|in", "query"); + $this->openapi->set("components|parameters|include|schema|type", "string"); + $this->openapi->set("components|parameters|include|description", "Columns you want to include in the output (comma separated). Example: posts.*,categories.name"); + $this->openapi->set("components|parameters|include|required", false); + + $this->openapi->set("components|parameters|exclude|name", "exclude"); + $this->openapi->set("components|parameters|exclude|in", "query"); + $this->openapi->set("components|parameters|exclude|schema|type", "string"); + $this->openapi->set("components|parameters|exclude|description", "Columns you want to exclude from the output (comma separated). Example: posts.content"); + $this->openapi->set("components|parameters|exclude|required", false); + + $this->openapi->set("components|parameters|order|name", "order"); + $this->openapi->set("components|parameters|order|in", "query"); + $this->openapi->set("components|parameters|order|schema|type", "array"); + $this->openapi->set("components|parameters|order|schema|items|type", "string"); + $this->openapi->set("components|parameters|order|description", "Column you want to sort on and the sort direction (comma separated). Example: id,desc"); + $this->openapi->set("components|parameters|order|required", false); + + $this->openapi->set("components|parameters|size|name", "size"); + $this->openapi->set("components|parameters|size|in", "query"); + $this->openapi->set("components|parameters|size|schema|type", "string"); + $this->openapi->set("components|parameters|size|description", "Maximum number of results (for top lists). Example: 10"); + $this->openapi->set("components|parameters|size|required", false); + + $this->openapi->set("components|parameters|page|name", "page"); + $this->openapi->set("components|parameters|page|in", "query"); + $this->openapi->set("components|parameters|page|schema|type", "string"); + $this->openapi->set("components|parameters|page|description", "Page number and page size (comma separated). Example: 1,10"); + $this->openapi->set("components|parameters|page|required", false); + + $this->openapi->set("components|parameters|join|name", "join"); + $this->openapi->set("components|parameters|join|in", "query"); + $this->openapi->set("components|parameters|join|schema|type", "array"); + $this->openapi->set("components|parameters|join|schema|items|type", "string"); + $this->openapi->set("components|parameters|join|description", "Paths (comma separated) to related entities that you want to include. Example: comments,users"); + $this->openapi->set("components|parameters|join|required", false); + } + + private function setTag(int $index, string $tableName) /*: void*/ + { + $this->openapi->set("tags|$index|name", "$tableName"); + $this->openapi->set("tags|$index|description", "$tableName operations"); + } + } +} + +// file: src/Tqdev/PhpCrudApi/OpenApi/OpenApiService.php +namespace Tqdev\PhpCrudApi\OpenApi { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\OpenApi\OpenApiBuilder; + + class OpenApiService + { + private $builder; + + public function __construct(ReflectionService $reflection, array $base, array $controllers, array $customBuilders) + { + $this->builder = new OpenApiBuilder($reflection, $base, $controllers, $customBuilders); + } + + public function get(): OpenApiDefinition + { + return $this->builder->build(); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/AndCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class AndCondition extends Condition + { + private $conditions; + + public function __construct(Condition $condition1, Condition $condition2) + { + $this->conditions = [$condition1, $condition2]; + } + + public function _and(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + $this->conditions[] = $condition; + return $this; + } + + public function getConditions(): array + { + return $this->conditions; + } + + public static function fromArray(array $conditions): Condition + { + $condition = new NoCondition(); + foreach ($conditions as $c) { + $condition = $condition->_and($c); + } + return $condition; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/ColumnCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; + + class ColumnCondition extends Condition + { + private $column; + private $operator; + private $value; + + public function __construct(ReflectedColumn $column, string $operator, string $value) + { + $this->column = $column; + $this->operator = $operator; + $this->value = $value; + } + + public function getColumn(): ReflectedColumn + { + return $this->column; + } + + public function getOperator(): string + { + return $this->operator; + } + + public function getValue(): string + { + return $this->value; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/Condition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + abstract class Condition + { + public function _and(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + return new AndCondition($this, $condition); + } + + public function _or(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + return new OrCondition($this, $condition); + } + + public function _not(): Condition + { + return new NotCondition($this); + } + + public static function fromString(ReflectedTable $table, string $value): Condition + { + $condition = new NoCondition(); + $parts = explode(',', $value, 3); + if (count($parts) < 2) { + return $condition; + } + if (count($parts) < 3) { + $parts[2] = ''; + } + $field = $table->getColumn($parts[0]); + $command = $parts[1]; + $negate = false; + $spatial = false; + if (strlen($command) > 2) { + if (substr($command, 0, 1) == 'n') { + $negate = true; + $command = substr($command, 1); + } + if (substr($command, 0, 1) == 's') { + $spatial = true; + $command = substr($command, 1); + } + } + if ($spatial) { + if (in_array($command, ['co', 'cr', 'di', 'eq', 'in', 'ov', 'to', 'wi', 'ic', 'is', 'iv'])) { + $condition = new SpatialCondition($field, $command, $parts[2]); + } + } else { + if (in_array($command, ['cs', 'sw', 'ew', 'eq', 'lt', 'le', 'ge', 'gt', 'bt', 'in', 'is'])) { + $condition = new ColumnCondition($field, $command, $parts[2]); + } + } + if ($negate) { + $condition = $condition->_not(); + } + return $condition; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/NoCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class NoCondition extends Condition + { + public function _and(Condition $condition): Condition + { + return $condition; + } + + public function _or(Condition $condition): Condition + { + return $condition; + } + + public function _not(): Condition + { + return $this; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/NotCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class NotCondition extends Condition + { + private $condition; + + public function __construct(Condition $condition) + { + $this->condition = $condition; + } + + public function getCondition(): Condition + { + return $this->condition; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/OrCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class OrCondition extends Condition + { + private $conditions; + + public function __construct(Condition $condition1, Condition $condition2) + { + $this->conditions = [$condition1, $condition2]; + } + + public function _or(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + $this->conditions[] = $condition; + return $this; + } + + public function getConditions(): array + { + return $this->conditions; + } + + public static function fromArray(array $conditions): Condition + { + $condition = new NoCondition(); + foreach ($conditions as $c) { + $condition = $condition->_or($c); + } + return $condition; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Condition/SpatialCondition.php +namespace Tqdev\PhpCrudApi\Record\Condition { + + class SpatialCondition extends ColumnCondition + { + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Document/ErrorDocument.php +namespace Tqdev\PhpCrudApi\Record\Document { + + use Tqdev\PhpCrudApi\Record\ErrorCode; + + class ErrorDocument implements \JsonSerializable + { + public $code; + public $message; + public $details; + + public function __construct(ErrorCode $errorCode, string $argument, $details) + { + $this->code = $errorCode->getCode(); + $this->message = $errorCode->getMessage($argument); + $this->details = $details; + } + + public function getCode(): int + { + return $this->code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function serialize() + { + return [ + 'code' => $this->code, + 'message' => $this->message, + 'details' => $this->details, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize()); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/Document/ListDocument.php +namespace Tqdev\PhpCrudApi\Record\Document { + + class ListDocument implements \JsonSerializable + { + private $records; + + private $results; + + public function __construct(array $records, int $results) + { + $this->records = $records; + $this->results = $results; + } + + public function getRecords(): array + { + return $this->records; + } + + public function getResults(): int + { + return $this->results; + } + + public function serialize() + { + return [ + 'records' => $this->records, + 'results' => $this->results, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize(), function ($v) { + return $v !== 0; + }); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/ColumnIncluder.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + class ColumnIncluder + { + private function isMandatory(string $tableName, string $columnName, array $params): bool + { + return isset($params['mandatory']) && in_array($tableName . "." . $columnName, $params['mandatory']); + } + + private function select( + string $tableName, + bool $primaryTable, + array $params, + string $paramName, + array $columnNames, + bool $include + ): array { + if (!isset($params[$paramName])) { + return $columnNames; + } + $columns = array(); + foreach (explode(',', $params[$paramName][0]) as $columnName) { + $columns[$columnName] = true; + } + $result = array(); + foreach ($columnNames as $columnName) { + $match = isset($columns['*.*']); + if (!$match) { + $match = isset($columns[$tableName . '.*']) || isset($columns[$tableName . '.' . $columnName]); + } + if ($primaryTable && !$match) { + $match = isset($columns['*']) || isset($columns[$columnName]); + } + if ($match) { + if ($include || $this->isMandatory($tableName, $columnName, $params)) { + $result[] = $columnName; + } + } else { + if (!$include || $this->isMandatory($tableName, $columnName, $params)) { + $result[] = $columnName; + } + } + } + return $result; + } + + public function getNames(ReflectedTable $table, bool $primaryTable, array $params): array + { + $tableName = $table->getName(); + $results = $table->getColumnNames(); + $results = $this->select($tableName, $primaryTable, $params, 'include', $results, true); + $results = $this->select($tableName, $primaryTable, $params, 'exclude', $results, false); + return $results; + } + + public function getValues(ReflectedTable $table, bool $primaryTable, /* object */ $record, array $params): array + { + $results = array(); + $columnNames = $this->getNames($table, $primaryTable, $params); + foreach ($columnNames as $columnName) { + if (property_exists($record, $columnName)) { + $results[$columnName] = $record->$columnName; + } + } + return $results; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/ErrorCode.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\ResponseFactory; + + class ErrorCode + { + private $code; + private $message; + private $status; + + const ERROR_NOT_FOUND = 9999; + const ROUTE_NOT_FOUND = 1000; + const TABLE_NOT_FOUND = 1001; + const ARGUMENT_COUNT_MISMATCH = 1002; + const RECORD_NOT_FOUND = 1003; + const ORIGIN_FORBIDDEN = 1004; + const COLUMN_NOT_FOUND = 1005; + const TABLE_ALREADY_EXISTS = 1006; + const COLUMN_ALREADY_EXISTS = 1007; + const HTTP_MESSAGE_NOT_READABLE = 1008; + const DUPLICATE_KEY_EXCEPTION = 1009; + const DATA_INTEGRITY_VIOLATION = 1010; + const AUTHENTICATION_REQUIRED = 1011; + const AUTHENTICATION_FAILED = 1012; + const INPUT_VALIDATION_FAILED = 1013; + const OPERATION_FORBIDDEN = 1014; + const OPERATION_NOT_SUPPORTED = 1015; + const TEMPORARY_OR_PERMANENTLY_BLOCKED = 1016; + const BAD_OR_MISSING_XSRF_TOKEN = 1017; + const ONLY_AJAX_REQUESTS_ALLOWED = 1018; + const PAGINATION_FORBIDDEN = 1019; + const USER_ALREADY_EXIST = 1020; + const PASSWORD_TOO_SHORT = 1021; + + private $values = [ + 9999 => ["%s", ResponseFactory::INTERNAL_SERVER_ERROR], + 1000 => ["Route '%s' not found", ResponseFactory::NOT_FOUND], + 1001 => ["Table '%s' not found", ResponseFactory::NOT_FOUND], + 1002 => ["Argument count mismatch in '%s'", ResponseFactory::UNPROCESSABLE_ENTITY], + 1003 => ["Record '%s' not found", ResponseFactory::NOT_FOUND], + 1004 => ["Origin '%s' is forbidden", ResponseFactory::FORBIDDEN], + 1005 => ["Column '%s' not found", ResponseFactory::NOT_FOUND], + 1006 => ["Table '%s' already exists", ResponseFactory::CONFLICT], + 1007 => ["Column '%s' already exists", ResponseFactory::CONFLICT], + 1008 => ["Cannot read HTTP message", ResponseFactory::UNPROCESSABLE_ENTITY], + 1009 => ["Duplicate key exception", ResponseFactory::CONFLICT], + 1010 => ["Data integrity violation", ResponseFactory::CONFLICT], + 1011 => ["Authentication required", ResponseFactory::UNAUTHORIZED], + 1012 => ["Authentication failed for '%s'", ResponseFactory::FORBIDDEN], + 1013 => ["Input validation failed for '%s'", ResponseFactory::UNPROCESSABLE_ENTITY], + 1014 => ["Operation forbidden", ResponseFactory::FORBIDDEN], + 1015 => ["Operation '%s' not supported", ResponseFactory::METHOD_NOT_ALLOWED], + 1016 => ["Temporary or permanently blocked", ResponseFactory::FORBIDDEN], + 1017 => ["Bad or missing XSRF token", ResponseFactory::FORBIDDEN], + 1018 => ["Only AJAX requests allowed for '%s'", ResponseFactory::FORBIDDEN], + 1019 => ["Pagination forbidden", ResponseFactory::FORBIDDEN], + 1020 => ["User '%s' already exists", ResponseFactory::CONFLICT], + 1021 => ["Password too short (<%d characters)", ResponseFactory::UNPROCESSABLE_ENTITY], + ]; + + public function __construct(int $code) + { + if (!isset($this->values[$code])) { + $code = 9999; + } + $this->code = $code; + $this->message = $this->values[$code][0]; + $this->status = $this->values[$code][1]; + } + + public function getCode(): int + { + return $this->code; + } + + public function getMessage(string $argument): string + { + return sprintf($this->message, $argument); + } + + public function getStatus(): int + { + return $this->status; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/FilterInfo.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Record\Condition\AndCondition; + use Tqdev\PhpCrudApi\Record\Condition\Condition; + use Tqdev\PhpCrudApi\Record\Condition\NoCondition; + use Tqdev\PhpCrudApi\Record\Condition\OrCondition; + + class FilterInfo + { + private function getConditionsAsPathTree(ReflectedTable $table, array $params): PathTree + { + $conditions = new PathTree(); + foreach ($params as $key => $filters) { + if (substr($key, 0, 6) == 'filter') { + preg_match_all('/\d+|\D+/', substr($key, 6), $matches); + $path = $matches[0]; + foreach ($filters as $filter) { + $condition = Condition::fromString($table, $filter); + if (($condition instanceof NoCondition) == false) { + $conditions->put($path, $condition); + } + } + } + } + return $conditions; + } + + private function combinePathTreeOfConditions(PathTree $tree): Condition + { + $andConditions = $tree->getValues(); + $and = AndCondition::fromArray($andConditions); + $orConditions = []; + foreach ($tree->getKeys() as $p) { + $orConditions[] = $this->combinePathTreeOfConditions($tree->get($p)); + } + $or = OrCondition::fromArray($orConditions); + return $and->_and($or); + } + + public function getCombinedConditions(ReflectedTable $table, array $params): Condition + { + return $this->combinePathTreeOfConditions($this->getConditionsAsPathTree($table, $params)); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/HabtmValues.php +namespace Tqdev\PhpCrudApi\Record { + + class HabtmValues + { + public $pkValues; + public $fkValues; + + public function __construct(array $pkValues, array $fkValues) + { + $this->pkValues = $pkValues; + $this->fkValues = $fkValues; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/OrderingInfo.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + + class OrderingInfo + { + public function getColumnOrdering(ReflectedTable $table, array $params): array + { + $fields = array(); + if (isset($params['order'])) { + foreach ($params['order'] as $order) { + $parts = explode(',', $order, 3); + $columnName = $parts[0]; + if (!$table->hasColumn($columnName)) { + continue; + } + $ascending = 'ASC'; + if (count($parts) > 1) { + if (substr(strtoupper($parts[1]), 0, 4) == "DESC") { + $ascending = 'DESC'; + } + } + $fields[] = [$columnName, $ascending]; + } + } + if (count($fields) == 0) { + return $this->getDefaultColumnOrdering($table); + } + return $fields; + } + + public function getDefaultColumnOrdering(ReflectedTable $table): array + { + $fields = array(); + $pk = $table->getPk(); + if ($pk) { + $fields[] = [$pk->getName(), 'ASC']; + } else { + foreach ($table->getColumnNames() as $columnName) { + $fields[] = [$columnName, 'ASC']; + } + } + return $fields; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/PaginationInfo.php +namespace Tqdev\PhpCrudApi\Record { + + class PaginationInfo + { + public $DEFAULT_PAGE_SIZE = 20; + + public function hasPage(array $params): bool + { + return isset($params['page']); + } + + public function getPageOffset(array $params): int + { + $offset = 0; + $pageSize = $this->getPageSize($params); + if (isset($params['page'])) { + foreach ($params['page'] as $page) { + $parts = explode(',', $page, 2); + $page = intval($parts[0]) - 1; + $offset = $page * $pageSize; + } + } + return $offset; + } + + private function getPageSize(array $params): int + { + $pageSize = $this->DEFAULT_PAGE_SIZE; + if (isset($params['page'])) { + foreach ($params['page'] as $page) { + $parts = explode(',', $page, 2); + if (count($parts) > 1) { + $pageSize = intval($parts[1]); + } + } + } + return $pageSize; + } + + public function getResultSize(array $params): int + { + $numberOfRows = -1; + if (isset($params['size'])) { + foreach ($params['size'] as $size) { + $numberOfRows = intval($size); + } + } + return $numberOfRows; + } + + public function getPageLimit(array $params): int + { + $pageLimit = -1; + if ($this->hasPage($params)) { + $pageLimit = $this->getPageSize($params); + } + $resultSize = $this->getResultSize($params); + if ($resultSize >= 0) { + if ($pageLimit >= 0) { + $pageLimit = min($pageLimit, $resultSize); + } else { + $pageLimit = $resultSize; + } + } + return $pageLimit; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/PathTree.php +namespace Tqdev\PhpCrudApi\Record { + + class PathTree implements \JsonSerializable + { + const WILDCARD = '*'; + + private $tree; + + public function __construct(/* object */&$tree = null) + { + if (!$tree) { + $tree = $this->newTree(); + } + $this->tree = &$tree; + } + + public function newTree() + { + return (object) ['values' => [], 'branches' => (object) []]; + } + + public function getKeys(): array + { + $branches = (array) $this->tree->branches; + return array_keys($branches); + } + + public function getValues(): array + { + return $this->tree->values; + } + + public function get(string $key): PathTree + { + if (!isset($this->tree->branches->$key)) { + return null; + } + return new PathTree($this->tree->branches->$key); + } + + public function put(array $path, $value) + { + $tree = &$this->tree; + foreach ($path as $key) { + if (!isset($tree->branches->$key)) { + $tree->branches->$key = $this->newTree(); + } + $tree = &$tree->branches->$key; + } + $tree->values[] = $value; + } + + public function match(array $path): array + { + $star = self::WILDCARD; + $tree = &$this->tree; + foreach ($path as $key) { + if (isset($tree->branches->$key)) { + $tree = &$tree->branches->$key; + } elseif (isset($tree->branches->$star)) { + $tree = &$tree->branches->$star; + } else { + return []; + } + } + return $tree->values; + } + + public static function fromJson(/* object */$tree): PathTree + { + return new PathTree($tree); + } + + public function jsonSerialize() + { + return $this->tree; + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/RecordService.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\Record\Document\ListDocument; + + class RecordService + { + private $db; + private $reflection; + private $columns; + private $joiner; + private $filters; + private $ordering; + private $pagination; + + public function __construct(GenericDB $db, ReflectionService $reflection) + { + $this->db = $db; + $this->reflection = $reflection; + $this->columns = new ColumnIncluder(); + $this->joiner = new RelationJoiner($reflection, $this->columns); + $this->filters = new FilterInfo(); + $this->ordering = new OrderingInfo(); + $this->pagination = new PaginationInfo(); + } + + private function sanitizeRecord(string $tableName, /* object */ $record, string $id) + { + $keyset = array_keys((array) $record); + foreach ($keyset as $key) { + if (!$this->reflection->getTable($tableName)->hasColumn($key)) { + unset($record->$key); + } + } + if ($id != '') { + $pk = $this->reflection->getTable($tableName)->getPk(); + foreach ($this->reflection->getTable($tableName)->getColumnNames() as $key) { + $field = $this->reflection->getTable($tableName)->getColumn($key); + if ($field->getName() == $pk->getName()) { + unset($record->$key); + } + } + } + } + + public function hasTable(string $table): bool + { + return $this->reflection->hasTable($table); + } + + public function getType(string $table): string + { + return $this->reflection->getType($table); + } + + public function create(string $tableName, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, ''); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->createSingle($table, $columnValues); + } + + public function read(string $tableName, string $id, array $params) /*: ?object*/ + { + $table = $this->reflection->getTable($tableName); + $this->joiner->addMandatoryColumns($table, $params); + $columnNames = $this->columns->getNames($table, true, $params); + $record = $this->db->selectSingle($table, $columnNames, $id); + if ($record == null) { + return null; + } + $records = array($record); + $this->joiner->addJoins($table, $records, $params, $this->db); + return $records[0]; + } + + public function update(string $tableName, string $id, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, $id); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->updateSingle($table, $columnValues, $id); + } + + public function delete(string $tableName, string $id, array $params) /*: ?int*/ + { + $table = $this->reflection->getTable($tableName); + return $this->db->deleteSingle($table, $id); + } + + public function increment(string $tableName, string $id, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, $id); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->incrementSingle($table, $columnValues, $id); + } + + public function _list(string $tableName, array $params): ListDocument + { + $table = $this->reflection->getTable($tableName); + $this->joiner->addMandatoryColumns($table, $params); + $columnNames = $this->columns->getNames($table, true, $params); + $condition = $this->filters->getCombinedConditions($table, $params); + $columnOrdering = $this->ordering->getColumnOrdering($table, $params); + if (!$this->pagination->hasPage($params)) { + $offset = 0; + $limit = $this->pagination->getPageLimit($params); + $count = 0; + } else { + $offset = $this->pagination->getPageOffset($params); + $limit = $this->pagination->getPageLimit($params); + $count = $this->db->selectCount($table, $condition); + } + $records = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, $offset, $limit); + $this->joiner->addJoins($table, $records, $params, $this->db); + return new ListDocument($records, $count); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Record/RelationJoiner.php +namespace Tqdev\PhpCrudApi\Record { + + use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\Middleware\Communication\VariableStore; + use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition; + use Tqdev\PhpCrudApi\Record\Condition\OrCondition; + + class RelationJoiner + { + private $reflection; + private $ordering; + private $columns; + + public function __construct(ReflectionService $reflection, ColumnIncluder $columns) + { + $this->reflection = $reflection; + $this->ordering = new OrderingInfo(); + $this->columns = $columns; + } + + public function addMandatoryColumns(ReflectedTable $table, array &$params) /*: void*/ + { + if (!isset($params['join']) || !isset($params['include'])) { + return; + } + $params['mandatory'] = array(); + foreach ($params['join'] as $tableNames) { + $t1 = $table; + foreach (explode(',', $tableNames) as $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $t2 = $this->reflection->getTable($tableName); + $fks1 = $t1->getFksTo($t2->getName()); + $t3 = $this->hasAndBelongsToMany($t1, $t2); + if ($t3 != null || count($fks1) > 0) { + $params['mandatory'][] = $t2->getName() . '.' . $t2->getPk()->getName(); + } + foreach ($fks1 as $fk) { + $params['mandatory'][] = $t1->getName() . '.' . $fk->getName(); + } + $fks2 = $t2->getFksTo($t1->getName()); + if ($t3 != null || count($fks2) > 0) { + $params['mandatory'][] = $t1->getName() . '.' . $t1->getPk()->getName(); + } + foreach ($fks2 as $fk) { + $params['mandatory'][] = $t2->getName() . '.' . $fk->getName(); + } + $t1 = $t2; + } + } + } + + private function getJoinsAsPathTree(array $params): PathTree + { + $joins = new PathTree(); + if (isset($params['join'])) { + foreach ($params['join'] as $tableNames) { + $path = array(); + foreach (explode(',', $tableNames) as $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $t = $this->reflection->getTable($tableName); + if ($t != null) { + $path[] = $t->getName(); + } + } + $joins->put($path, true); + } + } + return $joins; + } + + public function addJoins(ReflectedTable $table, array &$records, array $params, GenericDB $db) /*: void*/ + { + $joins = $this->getJoinsAsPathTree($params); + $this->addJoinsForTables($table, $joins, $records, $params, $db); + } + + private function hasAndBelongsToMany(ReflectedTable $t1, ReflectedTable $t2) /*: ?ReflectedTable*/ + { + foreach ($this->reflection->getTableNames() as $tableName) { + $t3 = $this->reflection->getTable($tableName); + if (count($t3->getFksTo($t1->getName())) > 0 && count($t3->getFksTo($t2->getName())) > 0) { + return $t3; + } + } + return null; + } + + private function addJoinsForTables(ReflectedTable $t1, PathTree $joins, array &$records, array $params, GenericDB $db) + { + foreach ($joins->getKeys() as $t2Name) { + $t2 = $this->reflection->getTable($t2Name); + + $belongsTo = count($t1->getFksTo($t2->getName())) > 0; + $hasMany = count($t2->getFksTo($t1->getName())) > 0; + if (!$belongsTo && !$hasMany) { + $t3 = $this->hasAndBelongsToMany($t1, $t2); + } else { + $t3 = null; + } + $hasAndBelongsToMany = ($t3 != null); + + $newRecords = array(); + $fkValues = null; + $pkValues = null; + $habtmValues = null; + + if ($belongsTo) { + $fkValues = $this->getFkEmptyValues($t1, $t2, $records); + $this->addFkRecords($t2, $fkValues, $params, $db, $newRecords); + } + if ($hasMany) { + $pkValues = $this->getPkEmptyValues($t1, $records); + $this->addPkRecords($t1, $t2, $pkValues, $params, $db, $newRecords); + } + if ($hasAndBelongsToMany) { + $habtmValues = $this->getHabtmEmptyValues($t1, $t2, $t3, $db, $records); + $this->addFkRecords($t2, $habtmValues->fkValues, $params, $db, $newRecords); + } + + $this->addJoinsForTables($t2, $joins->get($t2Name), $newRecords, $params, $db); + + if ($fkValues != null) { + $this->fillFkValues($t2, $newRecords, $fkValues); + $this->setFkValues($t1, $t2, $records, $fkValues); + } + if ($pkValues != null) { + $this->fillPkValues($t1, $t2, $newRecords, $pkValues); + $this->setPkValues($t1, $t2, $records, $pkValues); + } + if ($habtmValues != null) { + $this->fillFkValues($t2, $newRecords, $habtmValues->fkValues); + $this->setHabtmValues($t1, $t2, $records, $habtmValues); + } + } + } + + private function getFkEmptyValues(ReflectedTable $t1, ReflectedTable $t2, array $records): array + { + $fkValues = array(); + $fks = $t1->getFksTo($t2->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($records as $record) { + if (isset($record[$fkName])) { + $fkValue = $record[$fkName]; + $fkValues[$fkValue] = null; + } + } + } + return $fkValues; + } + + private function addFkRecords(ReflectedTable $t2, array $fkValues, array $params, GenericDB $db, array &$records) /*: void*/ + { + $columnNames = $this->columns->getNames($t2, false, $params); + $fkIds = array_keys($fkValues); + + foreach ($db->selectMultiple($t2, $columnNames, $fkIds) as $record) { + $records[] = $record; + } + } + + private function fillFkValues(ReflectedTable $t2, array $fkRecords, array &$fkValues) /*: void*/ + { + $pkName = $t2->getPk()->getName(); + foreach ($fkRecords as $fkRecord) { + $pkValue = $fkRecord[$pkName]; + $fkValues[$pkValue] = $fkRecord; + } + } + + private function setFkValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, array $fkValues) /*: void*/ + { + $fks = $t1->getFksTo($t2->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($records as $i => $record) { + if (isset($record[$fkName])) { + $key = $record[$fkName]; + $records[$i][$fkName] = $fkValues[$key]; + } + } + } + } + + private function getPkEmptyValues(ReflectedTable $t1, array $records): array + { + $pkValues = array(); + $pkName = $t1->getPk()->getName(); + foreach ($records as $record) { + $key = $record[$pkName]; + $pkValues[$key] = array(); + } + return $pkValues; + } + + private function addPkRecords(ReflectedTable $t1, ReflectedTable $t2, array $pkValues, array $params, GenericDB $db, array &$records) /*: void*/ + { + $fks = $t2->getFksTo($t1->getName()); + $columnNames = $this->columns->getNames($t2, false, $params); + $pkValueKeys = implode(',', array_keys($pkValues)); + $conditions = array(); + foreach ($fks as $fk) { + $conditions[] = new ColumnCondition($fk, 'in', $pkValueKeys); + } + $condition = OrCondition::fromArray($conditions); + $columnOrdering = array(); + $limit = VariableStore::get("joinLimits.maxRecords") ?: -1; + if ($limit != -1) { + $columnOrdering = $this->ordering->getDefaultColumnOrdering($t2); + } + foreach ($db->selectAll($t2, $columnNames, $condition, $columnOrdering, 0, $limit) as $record) { + $records[] = $record; + } + } + + private function fillPkValues(ReflectedTable $t1, ReflectedTable $t2, array $pkRecords, array &$pkValues) /*: void*/ + { + $fks = $t2->getFksTo($t1->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($pkRecords as $pkRecord) { + $key = $pkRecord[$fkName]; + if (isset($pkValues[$key])) { + $pkValues[$key][] = $pkRecord; + } + } + } + } + + private function setPkValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, array $pkValues) /*: void*/ + { + $pkName = $t1->getPk()->getName(); + $t2Name = $t2->getName(); + + foreach ($records as $i => $record) { + $key = $record[$pkName]; + $records[$i][$t2Name] = $pkValues[$key]; + } + } + + private function getHabtmEmptyValues(ReflectedTable $t1, ReflectedTable $t2, ReflectedTable $t3, GenericDB $db, array $records): HabtmValues + { + $pkValues = $this->getPkEmptyValues($t1, $records); + $fkValues = array(); + + $fk1 = $t3->getFksTo($t1->getName())[0]; + $fk2 = $t3->getFksTo($t2->getName())[0]; + + $fk1Name = $fk1->getName(); + $fk2Name = $fk2->getName(); + + $columnNames = array($fk1Name, $fk2Name); + + $pkIds = implode(',', array_keys($pkValues)); + $condition = new ColumnCondition($t3->getColumn($fk1Name), 'in', $pkIds); + $columnOrdering = array(); + + $limit = VariableStore::get("joinLimits.maxRecords") ?: -1; + if ($limit != -1) { + $columnOrdering = $this->ordering->getDefaultColumnOrdering($t3); + } + $records = $db->selectAll($t3, $columnNames, $condition, $columnOrdering, 0, $limit); + foreach ($records as $record) { + $val1 = $record[$fk1Name]; + $val2 = $record[$fk2Name]; + $pkValues[$val1][] = $val2; + $fkValues[$val2] = null; + } + + return new HabtmValues($pkValues, $fkValues); + } + + private function setHabtmValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, HabtmValues $habtmValues) /*: void*/ + { + $pkName = $t1->getPk()->getName(); + $t2Name = $t2->getName(); + foreach ($records as $i => $record) { + $key = $record[$pkName]; + $val = array(); + $fks = $habtmValues->pkValues[$key]; + foreach ($fks as $fk) { + $val[] = $habtmValues->fkValues[$fk]; + } + $records[$i][$t2Name] = $val; + } + } + } +} + +// file: src/Tqdev/PhpCrudApi/Api.php +namespace Tqdev\PhpCrudApi { + + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\RequestHandlerInterface; + use Tqdev\PhpCrudApi\Cache\CacheFactory; + use Tqdev\PhpCrudApi\Column\DefinitionService; + use Tqdev\PhpCrudApi\Column\ReflectionService; + use Tqdev\PhpCrudApi\Controller\CacheController; + use Tqdev\PhpCrudApi\Controller\ColumnController; + use Tqdev\PhpCrudApi\Controller\GeoJsonController; + use Tqdev\PhpCrudApi\Controller\JsonResponder; + use Tqdev\PhpCrudApi\Controller\OpenApiController; + use Tqdev\PhpCrudApi\Controller\RecordController; + use Tqdev\PhpCrudApi\Database\GenericDB; + use Tqdev\PhpCrudApi\GeoJson\GeoJsonService; + use Tqdev\PhpCrudApi\Middleware\AuthorizationMiddleware; + use Tqdev\PhpCrudApi\Middleware\BasicAuthMiddleware; + use Tqdev\PhpCrudApi\Middleware\CorsMiddleware; + use Tqdev\PhpCrudApi\Middleware\CustomizationMiddleware; + use Tqdev\PhpCrudApi\Middleware\DbAuthMiddleware; + use Tqdev\PhpCrudApi\Middleware\FirewallMiddleware; + use Tqdev\PhpCrudApi\Middleware\IpAddressMiddleware; + use Tqdev\PhpCrudApi\Middleware\JoinLimitsMiddleware; + use Tqdev\PhpCrudApi\Middleware\JwtAuthMiddleware; + use Tqdev\PhpCrudApi\Middleware\MultiTenancyMiddleware; + use Tqdev\PhpCrudApi\Middleware\PageLimitsMiddleware; + use Tqdev\PhpCrudApi\Middleware\ReconnectMiddleware; + use Tqdev\PhpCrudApi\Middleware\Router\SimpleRouter; + use Tqdev\PhpCrudApi\Middleware\SanitationMiddleware; + use Tqdev\PhpCrudApi\Middleware\SslRedirectMiddleware; + use Tqdev\PhpCrudApi\Middleware\ValidationMiddleware; + use Tqdev\PhpCrudApi\Middleware\XmlMiddleware; + use Tqdev\PhpCrudApi\Middleware\XsrfMiddleware; + use Tqdev\PhpCrudApi\OpenApi\OpenApiService; + use Tqdev\PhpCrudApi\Record\ErrorCode; + use Tqdev\PhpCrudApi\Record\RecordService; + use Tqdev\PhpCrudApi\ResponseUtils; + + class Api implements RequestHandlerInterface + { + private $router; + private $responder; + private $debug; + + public function __construct(Config $config) + { + $db = new GenericDB( + $config->getDriver(), + $config->getAddress(), + $config->getPort(), + $config->getDatabase(), + $config->getTables(), + $config->getUsername(), + $config->getPassword() + ); + $prefix = sprintf('phpcrudapi-%s-', substr(md5(__FILE__), 0, 8)); + $cache = CacheFactory::create($config->getCacheType(), $prefix, $config->getCachePath()); + $reflection = new ReflectionService($db, $cache, $config->getCacheTime()); + $responder = new JsonResponder(); + $router = new SimpleRouter($config->getBasePath(), $responder, $cache, $config->getCacheTime(), $config->getDebug()); + foreach ($config->getMiddlewares() as $middleware => $properties) { + switch ($middleware) { + case 'sslRedirect': + new SslRedirectMiddleware($router, $responder, $properties); + break; + case 'cors': + new CorsMiddleware($router, $responder, $properties, $config->getDebug()); + break; + case 'firewall': + new FirewallMiddleware($router, $responder, $properties); + break; + case 'basicAuth': + new BasicAuthMiddleware($router, $responder, $properties); + break; + case 'jwtAuth': + new JwtAuthMiddleware($router, $responder, $properties); + break; + case 'dbAuth': + new DbAuthMiddleware($router, $responder, $properties, $reflection, $db); + break; + case 'reconnect': + new ReconnectMiddleware($router, $responder, $properties, $reflection, $db); + break; + case 'validation': + new ValidationMiddleware($router, $responder, $properties, $reflection); + break; + case 'ipAddress': + new IpAddressMiddleware($router, $responder, $properties, $reflection); + break; + case 'sanitation': + new SanitationMiddleware($router, $responder, $properties, $reflection); + break; + case 'multiTenancy': + new MultiTenancyMiddleware($router, $responder, $properties, $reflection); + break; + case 'authorization': + new AuthorizationMiddleware($router, $responder, $properties, $reflection); + break; + case 'xsrf': + new XsrfMiddleware($router, $responder, $properties); + break; + case 'pageLimits': + new PageLimitsMiddleware($router, $responder, $properties, $reflection); + break; + case 'joinLimits': + new JoinLimitsMiddleware($router, $responder, $properties, $reflection); + break; + case 'customization': + new CustomizationMiddleware($router, $responder, $properties, $reflection); + break; + case 'xml': + new XmlMiddleware($router, $responder, $properties, $reflection); + break; + } + } + foreach ($config->getControllers() as $controller) { + switch ($controller) { + case 'records': + $records = new RecordService($db, $reflection); + new RecordController($router, $responder, $records); + break; + case 'columns': + $definition = new DefinitionService($db, $reflection); + new ColumnController($router, $responder, $reflection, $definition); + break; + case 'cache': + new CacheController($router, $responder, $cache); + break; + case 'openapi': + $openApi = new OpenApiService($reflection, $config->getOpenApiBase(), $config->getControllers(), $config->getCustomOpenApiBuilders()); + new OpenApiController($router, $responder, $openApi); + break; + case 'geojson': + $records = new RecordService($db, $reflection); + $geoJson = new GeoJsonService($reflection, $records); + new GeoJsonController($router, $responder, $geoJson); + break; + } + } + foreach ($config->getCustomControllers() as $className) { + if (class_exists($className)) { + $records = new RecordService($db, $reflection); + new $className($router, $responder, $records); + } + } + $this->router = $router; + $this->responder = $responder; + $this->debug = $config->getDebug(); + } + + private function parseBody(string $body) /*: ?object*/ + { + $first = substr($body, 0, 1); + if ($first == '[' || $first == '{') { + $object = json_decode($body); + $causeCode = json_last_error(); + if ($causeCode !== JSON_ERROR_NONE) { + $object = null; + } + } else { + parse_str($body, $input); + foreach ($input as $key => $value) { + if (substr($key, -9) == '__is_null') { + $input[substr($key, 0, -9)] = null; + unset($input[$key]); + } + } + $object = (object) $input; + } + return $object; + } + + private function addParsedBody(ServerRequestInterface $request): ServerRequestInterface + { + $parsedBody = $request->getParsedBody(); + if ($parsedBody) { + $request = $this->applyParsedBodyHack($request); + } else { + $body = $request->getBody(); + if ($body->isReadable()) { + if ($body->isSeekable()) { + $body->rewind(); + } + $contents = $body->getContents(); + if ($body->isSeekable()) { + $body->rewind(); + } + if ($contents) { + $parsedBody = $this->parseBody($contents); + $request = $request->withParsedBody($parsedBody); + } + } + } + return $request; + } + + private function applyParsedBodyHack(ServerRequestInterface $request): ServerRequestInterface + { + $parsedBody = $request->getParsedBody(); + if (is_array($parsedBody)) { // is it really? + $contents = json_encode($parsedBody); + $parsedBody = $this->parseBody($contents); + $request = $request->withParsedBody($parsedBody); + } + return $request; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->router->route($this->addParsedBody($request)); + } + } +} + +// file: src/Tqdev/PhpCrudApi/Config.php +namespace Tqdev\PhpCrudApi { + + class Config + { + private $values = [ + 'driver' => null, + 'address' => 'localhost', + 'port' => null, + 'username' => null, + 'password' => null, + 'database' => null, + 'tables' => '', + 'middlewares' => 'cors,errors', + 'controllers' => 'records,geojson,openapi', + 'customControllers' => '', + 'customOpenApiBuilders' => '', + 'cacheType' => 'TempFile', + 'cachePath' => '', + 'cacheTime' => 10, + 'debug' => false, + 'basePath' => '', + 'openApiBase' => '{"info":{"title":"PHP-CRUD-API","version":"1.0.0"}}', + ]; + + private function getDefaultDriver(array $values): string + { + if (isset($values['driver'])) { + return $values['driver']; + } + return 'mysql'; + } + + private function getDefaultPort(string $driver): int + { + switch ($driver) { + case 'mysql': + return 3306; + case 'pgsql': + return 5432; + case 'sqlsrv': + return 1433; + case 'sqlite': + return 0; + } + } + + private function getDefaultAddress(string $driver): string + { + switch ($driver) { + case 'mysql': + return 'localhost'; + case 'pgsql': + return 'localhost'; + case 'sqlsrv': + return 'localhost'; + case 'sqlite': + return 'data.db'; + } + } + + private function getDriverDefaults(string $driver): array + { + return [ + 'driver' => $driver, + 'address' => $this->getDefaultAddress($driver), + 'port' => $this->getDefaultPort($driver), + ]; + } + + private function applyEnvironmentVariables(array $values): array + { + $newValues = array(); + foreach ($values as $key => $value) { + $environmentKey = 'PHP_CRUD_API_' . strtoupper(preg_replace('/(?getDefaultDriver($values); + $defaults = $this->getDriverDefaults($driver); + $newValues = array_merge($this->values, $defaults, $values); + $newValues = $this->parseMiddlewares($newValues); + $diff = array_diff_key($newValues, $this->values); + if (!empty($diff)) { + $key = array_keys($diff)[0]; + throw new \Exception("Config has invalid value '$key'"); + } + $newValues = $this->applyEnvironmentVariables($newValues); + $this->values = $newValues; + } + + private function parseMiddlewares(array $values): array + { + $newValues = array(); + $properties = array(); + $middlewares = array_map('trim', explode(',', $values['middlewares'])); + foreach ($middlewares as $middleware) { + $properties[$middleware] = []; + } + foreach ($values as $key => $value) { + if (strpos($key, '.') === false) { + $newValues[$key] = $value; + } else { + list($middleware, $key2) = explode('.', $key, 2); + if (isset($properties[$middleware])) { + $properties[$middleware][$key2] = $value; + } else { + throw new \Exception("Config has invalid value '$key'"); + } + } + } + $newValues['middlewares'] = array_reverse($properties, true); + return $newValues; + } + + public function getDriver(): string + { + return $this->values['driver']; + } + + public function getAddress(): string + { + return $this->values['address']; + } + + public function getPort(): int + { + return $this->values['port']; + } + + public function getUsername(): string + { + return $this->values['username']; + } + + public function getPassword(): string + { + return $this->values['password']; + } + + public function getDatabase(): string + { + return $this->values['database']; + } + + public function getTables(): array + { + return array_filter(array_map('trim', explode(',', $this->values['tables']))); + } + + public function getMiddlewares(): array + { + return $this->values['middlewares']; + } + + public function getControllers(): array + { + return array_filter(array_map('trim', explode(',', $this->values['controllers']))); + } + + public function getCustomControllers(): array + { + return array_filter(array_map('trim', explode(',', $this->values['customControllers']))); + } + + public function getCustomOpenApiBuilders(): array + { + return array_filter(array_map('trim', explode(',', $this->values['customOpenApiBuilders']))); + } + + public function getCacheType(): string + { + return $this->values['cacheType']; + } + + public function getCachePath(): string + { + return $this->values['cachePath']; + } + + public function getCacheTime(): int + { + return $this->values['cacheTime']; + } + + public function getDebug(): bool + { + return $this->values['debug']; + } + + public function getBasePath(): string + { + return $this->values['basePath']; + } + + public function getOpenApiBase(): array + { + return json_decode($this->values['openApiBase'], true); + } + } +} + +// file: src/Tqdev/PhpCrudApi/RequestFactory.php +namespace Tqdev\PhpCrudApi { + + use Nyholm\Psr7\Factory\Psr17Factory; + use Nyholm\Psr7Server\ServerRequestCreator; + use Psr\Http\Message\ServerRequestInterface; + + class RequestFactory + { + public static function fromGlobals(): ServerRequestInterface + { + $psr17Factory = new Psr17Factory(); + $creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + $serverRequest = $creator->fromGlobals(); + $stream = $psr17Factory->createStreamFromFile('php://input'); + $serverRequest = $serverRequest->withBody($stream); + return $serverRequest; + } + + public static function fromString(string $request): ServerRequestInterface + { + $parts = explode("\n\n", trim($request), 2); + $lines = explode("\n", $parts[0]); + $first = explode(' ', trim(array_shift($lines)), 2); + $method = $first[0]; + $body = isset($parts[1]) ? $parts[1] : ''; + $url = isset($first[1]) ? $first[1] : ''; + + $psr17Factory = new Psr17Factory(); + $serverRequest = $psr17Factory->createServerRequest($method, $url); + foreach ($lines as $line) { + list($key, $value) = explode(':', $line, 2); + $serverRequest = $serverRequest->withAddedHeader($key, $value); + } + if ($body) { + $stream = $psr17Factory->createStream($body); + $stream->rewind(); + $serverRequest = $serverRequest->withBody($stream); + } + return $serverRequest; + } + } +} + +// file: src/Tqdev/PhpCrudApi/RequestUtils.php +namespace Tqdev\PhpCrudApi { + + use Psr\Http\Message\ServerRequestInterface; + use Tqdev\PhpCrudApi\Column\ReflectionService; + + class RequestUtils + { + public static function setParams(ServerRequestInterface $request, array $params): ServerRequestInterface + { + $query = preg_replace('|%5B[0-9]+%5D=|', '=', http_build_query($params)); + return $request->withUri($request->getUri()->withQuery($query)); + } + + public static function getHeader(ServerRequestInterface $request, string $header): string + { + $headers = $request->getHeader($header); + return isset($headers[0]) ? $headers[0] : ''; + } + + public static function getParams(ServerRequestInterface $request): array + { + $params = array(); + $query = $request->getUri()->getQuery(); + //$query = str_replace('][]=', ']=', str_replace('=', '[]=', $query)); + $query = str_replace('%5D%5B%5D=', '%5D=', str_replace('=', '%5B%5D=', $query)); + parse_str($query, $params); + return $params; + } + + public static function getPathSegment(ServerRequestInterface $request, int $part): string + { + $path = $request->getUri()->getPath(); + $pathSegments = explode('/', rtrim($path, '/')); + if ($part < 0 || $part >= count($pathSegments)) { + return ''; + } + return urldecode($pathSegments[$part]); + } + + public static function getOperation(ServerRequestInterface $request): string + { + $method = $request->getMethod(); + $path = RequestUtils::getPathSegment($request, 1); + $hasPk = RequestUtils::getPathSegment($request, 3) != ''; + switch ($path) { + case 'openapi': + return 'document'; + case 'columns': + return $method == 'get' ? 'reflect' : 'remodel'; + case 'geojson': + case 'records': + switch ($method) { + case 'POST': + return 'create'; + case 'GET': + return $hasPk ? 'read' : 'list'; + case 'PUT': + return 'update'; + case 'DELETE': + return 'delete'; + case 'PATCH': + return 'increment'; + } + } + return 'unknown'; + } + + private static function getJoinTables(string $tableName, array $parameters): array + { + $uniqueTableNames = array(); + $uniqueTableNames[$tableName] = true; + if (isset($parameters['join'])) { + foreach ($parameters['join'] as $parameter) { + $tableNames = explode(',', trim($parameter)); + foreach ($tableNames as $tableName) { + $uniqueTableNames[$tableName] = true; + } + } + } + return array_keys($uniqueTableNames); + } + + public static function getTableNames(ServerRequestInterface $request, ReflectionService $reflection): array + { + $path = RequestUtils::getPathSegment($request, 1); + $tableName = RequestUtils::getPathSegment($request, 2); + $allTableNames = $reflection->getTableNames(); + switch ($path) { + case 'openapi': + return $allTableNames; + case 'columns': + return $tableName ? [$tableName] : $allTableNames; + case 'records': + return self::getJoinTables($tableName, RequestUtils::getParams($request)); + } + return $allTableNames; + } + } +} + +// file: src/Tqdev/PhpCrudApi/ResponseFactory.php +namespace Tqdev\PhpCrudApi { + + use Nyholm\Psr7\Factory\Psr17Factory; + use Psr\Http\Message\ResponseInterface; + + class ResponseFactory + { + const OK = 200; + const MOVED_PERMANENTLY = 301; + const FOUND = 302; + const UNAUTHORIZED = 401; + const FORBIDDEN = 403; + const NOT_FOUND = 404; + const METHOD_NOT_ALLOWED = 405; + const CONFLICT = 409; + const UNPROCESSABLE_ENTITY = 422; + const INTERNAL_SERVER_ERROR = 500; + + public static function fromXml(int $status, string $xml): ResponseInterface + { + return self::from($status, 'text/xml', $xml); + } + + public static function fromCsv(int $status, string $csv): ResponseInterface + { + return self::from($status, 'text/csv', $csv); + } + + public static function fromHtml(int $status, string $html): ResponseInterface + { + return self::from($status, 'text/html', $html); + } + + public static function fromObject(int $status, $body): ResponseInterface + { + $content = json_encode($body, JSON_UNESCAPED_UNICODE); + return self::from($status, 'application/json', $content); + } + + public static function from(int $status, string $contentType, string $content): ResponseInterface + { + $psr17Factory = new Psr17Factory(); + $response = $psr17Factory->createResponse($status); + $stream = $psr17Factory->createStream($content); + $stream->rewind(); + $response = $response->withBody($stream); + $response = $response->withHeader('Content-Type', $contentType . '; charset=utf-8'); + $response = $response->withHeader('Content-Length', strlen($content)); + return $response; + } + + public static function fromStatus(int $status): ResponseInterface + { + $psr17Factory = new Psr17Factory(); + return $psr17Factory->createResponse($status); + } + } +} + +// file: src/Tqdev/PhpCrudApi/ResponseUtils.php +namespace Tqdev\PhpCrudApi { + + use Psr\Http\Message\ResponseInterface; + + class ResponseUtils + { + public static function output(ResponseInterface $response) + { + $status = $response->getStatusCode(); + $headers = $response->getHeaders(); + $body = $response->getBody()->getContents(); + + http_response_code($status); + foreach ($headers as $key => $values) { + foreach ($values as $value) { + header("$key: $value"); + } + } + echo $body; + } + + public static function addExceptionHeaders(ResponseInterface $response, \Throwable $e): ResponseInterface + { + $response = $response->withHeader('X-Exception-Name', get_class($e)); + $response = $response->withHeader('X-Exception-Message', preg_replace('|\n|', ' ', trim($e->getMessage()))); + $response = $response->withHeader('X-Exception-File', $e->getFile() . ':' . $e->getLine()); + return $response; + } + + public static function toString(ResponseInterface $response): string + { + $status = $response->getStatusCode(); + $headers = $response->getHeaders(); + $body = $response->getBody()->getContents(); + + $str = "$status\n"; + foreach ($headers as $key => $values) { + foreach ($values as $value) { + $str .= "$key: $value\n"; + } + } + if ($body !== '') { + $str .= "\n"; + $str .= "$body\n"; + } + return $str; + } + } +} + +// file: src/index.php +namespace Tqdev\PhpCrudApi { + + use Tqdev\PhpCrudApi\Api; + use Tqdev\PhpCrudApi\Config; + use Tqdev\PhpCrudApi\RequestFactory; + use Tqdev\PhpCrudApi\ResponseUtils; + + $config = new Config([ + // 'driver' => 'mysql', + // 'address' => 'localhost', + // 'port' => '3306', + 'username' => 'php-crud-api', + 'password' => 'php-crud-api', + 'database' => 'php-crud-api', + // 'debug' => false + ]); + $request = RequestFactory::fromGlobals(); + $api = new Api($config); + $response = $api->handle($request); + ResponseUtils::output($response); +} diff --git a/build.php b/build.php new file mode 100644 index 0000000..fe7335a --- /dev/null +++ b/build.php @@ -0,0 +1,113 @@ + $entry) { + if (isset($ignore[$dir . '/' . $entry])) { + unset($entries[$i]); + } + } +} + +function runDir(string $base, string $dir, array &$lines, array $ignore): int +{ + $count = 0; + $entries = scandir($dir); + removeIgnored($dir, $entries, $ignore); + sort($entries); + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $filename = "$base/$dir/$entry"; + if (is_dir($filename)) { + $count += runDir($base, "$dir/$entry", $lines, $ignore); + } + } + foreach ($entries as $entry) { + $filename = "$base/$dir/$entry"; + if (is_file($filename)) { + if (substr($entry, -4) != '.php') { + continue; + } + $data = file_get_contents($filename); + $data = preg_replace('/\s*<\?php\s+/s', '', $data, 1); + $data = preg_replace('/^.*?(vendor\/autoload|declare\s*\(\s*strict_types\s*=\s*1).*?$/m', '', $data); + array_push($lines, "// file: $dir/$entry"); + if (!preg_match('/^\s*(namespace[^;]*);/', $data)) { + $data = "namespace;\n" . $data; + } + foreach (explode("\n", trim($data)) as $line) { + if ($line) { + $line = ' ' . $line; + } + $line = preg_replace('/^\s*(namespace[^;]*);/', '\1 {', $line); + array_push($lines, $line); + } + array_push($lines, '}'); + array_push($lines, ''); + $count++; + } + } + return $count; +} + +function addHeader(array &$lines) +{ + $head = <<=7.0.0", + "ext-zlib": "*", + "ext-json": "*", + "ext-pdo": "*", + "psr/http-message": "*", + "psr/http-factory": "*", + "psr/http-server-handler": "*", + "psr/http-server-middleware": "*", + "nyholm/psr7": "*", + "nyholm/psr7-server": "*" + }, + "suggest": { + "ext-memcache": "*", + "ext-memcached": "*", + "ext-redis": "*" + }, + "autoload": { + "psr-4": { "Tqdev\\PhpCrudApi\\": "src/Tqdev/PhpCrudApi" } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..1660770 --- /dev/null +++ b/composer.lock @@ -0,0 +1,396 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3667d105fa59f4e36775fe664aef0f2f", + "packages": [ + { + "name": "nyholm/psr7", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "55ff6b76573f5b242554c9775792bd59fb52e11c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/55ff6b76573f5b242554c9775792bd59fb52e11c", + "reference": "55ff6b76573f5b242554c9775792bd59fb52e11c", + "shasum": "" + }, + "require": { + "php": "^7.1", + "php-http/message-factory": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "dev-master", + "php-http/psr7-integration-tests": "dev-master", + "phpunit/phpunit": "^7.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "time": "2019-09-05T13:24:16+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "0.4.1", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "e6a526e9170e6e33a13efc2b61703ca476b7ea68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/e6a526e9170e6e33a13efc2b61703ca476b7ea68", + "reference": "e6a526e9170e6e33a13efc2b61703ca476b7ea68", + "shasum": "" + }, + "require": { + "php": "^7.1", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.0", + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "time": "2019-11-05T20:36:33+00:00" + }, + { + "name": "php-http/message-factory", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/message-factory.git", + "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message-factory/zipball/a478cb11f66a6ac48d8954216cfed9aa06a501a1", + "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Factory interfaces for PSR-7 HTTP Message", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "stream", + "uri" + ], + "time": "2015-12-19T14:08:53+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "time": "2019-04-30T12:38:16+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "time": "2018-10-30T16:46:14+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "2296f45510945530b9dceb8bcedb5cb84d40c5f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/2296f45510945530b9dceb8bcedb5cb84d40c5f5", + "reference": "2296f45510945530b9dceb8bcedb5cb84d40c5f5", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "time": "2018-10-30T17:12:04+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=7.0.0", + "ext-zlib": "*", + "ext-json": "*", + "ext-pdo": "*" + }, + "platform-dev": [], + "plugin-api-version": "1.1.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..405da65 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +version: '3' +services: + database: + image: mysql:8.0 + container_name: database + restart: always + environment: + - MYSQL_ROOT_PASSWORD=php-crud-api + - MYSQL_DATABASE=php-crud-api + - MYSQL_USER=php-crud-api + - MYSQL_PASSWORD=php-crud-api + #ports: + #- "33066:3306" + volumes: + - ./tests/fixtures/blog_mysql.sql:/docker-entrypoint-initdb.d/blog_mysql.sql + webserver: + container_name: webserver + build: + context: ./ + environment: + #- PHP_CRUD_API_DRIVER=mysql + - PHP_CRUD_API_ADDRESS=database + #- PHP_CRUD_API_PORT=3306 + #- PHP_CRUD_API_DATABASE=php-crud-api + #- PHP_CRUD_API_USERNAME=php-crud-api + #- PHP_CRUD_API_PASSWORD=php-crud-api + #- PHP_CRUD_API_DEBUG=1 + ports: + - "8080:80" + depends_on: + - database diff --git a/docker/build_all.sh b/docker/build_all.sh new file mode 100755 index 0000000..24ef64e --- /dev/null +++ b/docker/build_all.sh @@ -0,0 +1,10 @@ +#!/bin/bash +FILES=* + +for f in $FILES +do +if [[ -d "$f" ]] +then + docker build "$f" -t "php-crud-api:$f" +fi +done diff --git a/docker/centos8/Dockerfile b/docker/centos8/Dockerfile new file mode 100644 index 0000000..625fdfc --- /dev/null +++ b/docker/centos8/Dockerfile @@ -0,0 +1,36 @@ +FROM centos:8 + +# add this to avoid locale warnings +RUN dnf -y install glibc-locale-source glibc-langpack-en +RUN localedef -i en_US -f UTF-8 en_US.UTF-8 + +# add utils for repos +RUN dnf -y install wget dnf-utils + +# enable remi repo for php +RUN dnf -y install http://rpms.remirepo.net/enterprise/remi-release-8.rpm +# enable mariadb repo +RUN wget https://downloads.mariadb.com/MariaDB/mariadb_repo_setup && bash mariadb_repo_setup +# enable the postgresql repo +RUN dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm +# enable epel repo +RUN dnf -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm +# enable powertools repos +RUN dnf -y install 'dnf-command(config-manager)' && dnf -y config-manager --set-enabled PowerTools + +# set php to remi 7.4 +RUN dnf -y module reset php && dnf -y module enable php:remi-7.4 +# disable mariadb and postgresql default (appstream) repo +RUN dnf -y module disable mariadb +RUN dnf -y module disable postgresql + +RUN dnf -y install \ +php-cli php-xml php-json php-mbstring \ +MariaDB-server MariaDB-client php-mysqlnd \ +postgresql12 postgresql12-server php-pgsql postgis30_12 \ +sqlite php-sqlite3 \ +git wget + +# install run script +ADD run.sh /usr/sbin/docker-run +CMD docker-run \ No newline at end of file diff --git a/docker/centos8/run.sh b/docker/centos8/run.sh new file mode 100755 index 0000000..189abe8 --- /dev/null +++ b/docker/centos8/run.sh @@ -0,0 +1,60 @@ +#!/bin/bash +echo "================================================" +echo " CentOS 8 (PHP 7.4)" +echo "================================================" +echo -n "[1/4] Starting MariaDB 10.5 ..... " +# initialize mysql +mysql_install_db > /dev/null +chown -R mysql:mysql /var/lib/mysql +# run mysql server +nohup /usr/sbin/mysqld -u mysql > /root/mysql.log 2>&1 & +# wait for mysql to become available +while ! mysqladmin ping -hlocalhost >/dev/null 2>&1; do + sleep 1 +done +# create database and user on mysql +mysql -u root >/dev/null << 'EOF' +CREATE DATABASE `php-crud-api` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +CREATE USER 'php-crud-api'@'localhost' IDENTIFIED BY 'php-crud-api'; +GRANT ALL PRIVILEGES ON `php-crud-api`.* TO 'php-crud-api'@'localhost' WITH GRANT OPTION; +FLUSH PRIVILEGES; +EOF +echo "done" + +echo -n "[2/4] Starting PostgreSQL 12.5 .. " +# initialize postgresql +su - -c "/usr/pgsql-12/bin/initdb --auth-local peer --auth-host password -D /var/lib/pgsql/data" postgres > /dev/null +# run postgres server +nohup su - -c "/usr/pgsql-12/bin/postgres -D /var/lib/pgsql/data" postgres > /root/postgres.log 2>&1 & +# wait for postgres to become available +until su - -c "psql -U postgres -c '\q'" postgres >/dev/null 2>&1; do + sleep 1; +done +# create database and user on postgres +su - -c "psql -U postgres >/dev/null" postgres << 'EOF' +CREATE USER "php-crud-api" WITH PASSWORD 'php-crud-api'; +CREATE DATABASE "php-crud-api"; +GRANT ALL PRIVILEGES ON DATABASE "php-crud-api" to "php-crud-api"; +\c "php-crud-api"; +CREATE EXTENSION IF NOT EXISTS postgis; +\q +EOF +echo "done" + +echo -n "[3/4] Starting SQLServer 2017 ... " +echo "skipped" + +echo -n "[4/4] Cloning PHP-CRUD-API v2 ... " +# install software +if [ -d /php-crud-api ]; then + echo "skipped" +else + git clone --quiet https://github.com/mevdschee/php-crud-api.git + echo "done" +fi + +echo "------------------------------------------------" + +# run the tests +cd php-crud-api +php test.php diff --git a/docker/clean_all.sh b/docker/clean_all.sh new file mode 100755 index 0000000..c6090a9 --- /dev/null +++ b/docker/clean_all.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Delete all containers +docker rm $(docker ps -a -q) +# Delete all images +docker rmi $(docker images -q) diff --git a/docker/debian10/Dockerfile b/docker/debian10/Dockerfile new file mode 100644 index 0000000..ac4e194 --- /dev/null +++ b/docker/debian10/Dockerfile @@ -0,0 +1,16 @@ +FROM debian:10 + +ARG DEBIAN_FRONTEND=noninteractive + +# install: php / mysql / postgres / sqlite / tools / mssql deps +RUN apt-get update && apt-get -y install \ +php-cli php-xml php-mbstring \ +mariadb-server mariadb-client php-mysql \ +postgresql php-pgsql \ +postgresql-11-postgis-2.5 \ +sqlite3 php-sqlite3 \ +git wget + +# install run script +ADD run.sh /usr/sbin/docker-run +CMD docker-run diff --git a/docker/debian10/run.sh b/docker/debian10/run.sh new file mode 100755 index 0000000..078cabf --- /dev/null +++ b/docker/debian10/run.sh @@ -0,0 +1,58 @@ +#!/bin/bash +echo "================================================" +echo " Debian 10 (PHP 7.3)" +echo "================================================" + +echo -n "[1/4] Starting MariaDB 10.3 ..... " +# make sure mysql can create socket and lock +mkdir /var/run/mysqld && chmod 777 /var/run/mysqld +# run mysql server +nohup mysqld > /root/mysql.log 2>&1 & +# wait for mysql to become available +while ! mysqladmin ping -hlocalhost >/dev/null 2>&1; do + sleep 1 +done +# create database and user on mysql +mysql -u root >/dev/null << 'EOF' +CREATE DATABASE `php-crud-api` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +CREATE USER 'php-crud-api'@'localhost' IDENTIFIED BY 'php-crud-api'; +GRANT ALL PRIVILEGES ON `php-crud-api`.* TO 'php-crud-api'@'localhost' WITH GRANT OPTION; +FLUSH PRIVILEGES; +EOF +echo "done" + +echo -n "[2/4] Starting PostgreSQL 11.4 .. " +# run postgres server +nohup su - -c "/usr/lib/postgresql/11/bin/postgres -D /etc/postgresql/11/main" postgres > /root/postgres.log 2>&1 & +# wait for postgres to become available +until su - -c "psql -U postgres -c '\q'" postgres >/dev/null 2>&1; do + sleep 1; +done +# create database and user on postgres +su - -c "psql -U postgres >/dev/null" postgres << 'EOF' +CREATE USER "php-crud-api" WITH PASSWORD 'php-crud-api'; +CREATE DATABASE "php-crud-api"; +GRANT ALL PRIVILEGES ON DATABASE "php-crud-api" to "php-crud-api"; +\c "php-crud-api"; +CREATE EXTENSION IF NOT EXISTS postgis; +\q +EOF +echo "done" + +echo -n "[3/4] Starting SQLServer 2017 ... " +echo "skipped" + +echo -n "[4/4] Cloning PHP-CRUD-API v2 ... " +# install software +if [ -d /php-crud-api ]; then + echo "skipped" +else + git clone --quiet https://github.com/mevdschee/php-crud-api.git + echo "done" +fi + +echo "------------------------------------------------" + +# run the tests +cd php-crud-api +php test.php diff --git a/docker/debian9/Dockerfile b/docker/debian9/Dockerfile new file mode 100644 index 0000000..4d2ae9c --- /dev/null +++ b/docker/debian9/Dockerfile @@ -0,0 +1,16 @@ +FROM debian:9 + +ARG DEBIAN_FRONTEND=noninteractive + +# install: php / mysql / postgres / sqlite / tools / mssql deps +RUN apt-get update && apt-get -y install \ +php-cli php-xml php-mbstring \ +mariadb-server mariadb-client php-mysql \ +postgresql php-pgsql \ +postgresql-9.6-postgis-2.3 \ +sqlite3 php-sqlite3 \ +git wget + +# install run script +ADD run.sh /usr/sbin/docker-run +CMD docker-run diff --git a/docker/debian9/run.sh b/docker/debian9/run.sh new file mode 100755 index 0000000..9bddef7 --- /dev/null +++ b/docker/debian9/run.sh @@ -0,0 +1,58 @@ +#!/bin/bash +echo "================================================" +echo " Debian 9 (PHP 7.0)" +echo "================================================" + +echo -n "[1/4] Starting MariaDB 10.1 ..... " +# make sure mysql can create socket and lock +mkdir /var/run/mysqld && chmod 777 /var/run/mysqld +# run mysql server +nohup mysqld > /root/mysql.log 2>&1 & +# wait for mysql to become available +while ! mysqladmin ping -hlocalhost >/dev/null 2>&1; do + sleep 1 +done +# create database and user on mysql +mysql -u root >/dev/null << 'EOF' +CREATE DATABASE `php-crud-api` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +CREATE USER 'php-crud-api'@'localhost' IDENTIFIED BY 'php-crud-api'; +GRANT ALL PRIVILEGES ON `php-crud-api`.* TO 'php-crud-api'@'localhost' WITH GRANT OPTION; +FLUSH PRIVILEGES; +EOF +echo "done" + +echo -n "[2/4] Starting PostgreSQL 9.6 ... " +# run postgres server +nohup su - -c "/usr/lib/postgresql/9.6/bin/postgres -D /etc/postgresql/9.6/main" postgres > /root/postgres.log 2>&1 & +# wait for postgres to become available +until su - -c "psql -U postgres -c '\q'" postgres >/dev/null 2>&1; do + sleep 1; +done +# create database and user on postgres +su - -c "psql -U postgres >/dev/null" postgres << 'EOF' +CREATE USER "php-crud-api" WITH PASSWORD 'php-crud-api'; +CREATE DATABASE "php-crud-api"; +GRANT ALL PRIVILEGES ON DATABASE "php-crud-api" to "php-crud-api"; +\c "php-crud-api"; +CREATE EXTENSION IF NOT EXISTS postgis; +\q +EOF +echo "done" + +echo -n "[3/4] Starting SQLServer 2017 ... " +echo "skipped" + +echo -n "[4/4] Cloning PHP-CRUD-API v2 ... " +# install software +if [ -d /php-crud-api ]; then + echo "skipped" +else + git clone --quiet https://github.com/mevdschee/php-crud-api.git + echo "done" +fi + +echo "------------------------------------------------" + +# run the tests +cd php-crud-api +php test.php diff --git a/docker/run.sh b/docker/run.sh new file mode 100755 index 0000000..743ccc8 --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +FILES=* +i=0 +options=() +for f in $FILES; do + if [[ -d "$f" ]]; then + ((i++)) + options[$i]=$f + fi +done +PS3="> " +select f in "${options[@]}"; do + if (( REPLY > 0 && REPLY <= ${#options[@]} )); then + break + else + exit + fi +done +dir=$(readlink -f ..) +docker rm "php-crud-api_$f" > /dev/null 2>&1 +docker run -ti -v $dir:/php-crud-api --name "php-crud-api_$f" "php-crud-api:$f" /bin/bash -c '/usr/sbin/docker-run && cd php-crud-api && /bin/bash' diff --git a/docker/run_all.sh b/docker/run_all.sh new file mode 100755 index 0000000..6d5d9dc --- /dev/null +++ b/docker/run_all.sh @@ -0,0 +1,12 @@ +#!/bin/bash +FILES=* + +for f in $FILES +do +if [[ -d "$f" ]] +then + dir=$(readlink -f ..) + docker rm "php-crud-api_$f" > /dev/null 2>&1 + docker run -ti -v $dir:/php-crud-api --name "php-crud-api_$f" "php-crud-api:$f" +fi +done diff --git a/docker/ubuntu16/Dockerfile b/docker/ubuntu16/Dockerfile new file mode 100644 index 0000000..f7f22f5 --- /dev/null +++ b/docker/ubuntu16/Dockerfile @@ -0,0 +1,37 @@ +FROM ubuntu:16.04 + +ARG DEBIAN_FRONTEND=noninteractive + +# install: php / mysql / postgres / tools / mssql deps +RUN apt-get update && apt-get -y install \ +php-cli php-xml php-mbstring \ +mariadb-server mariadb-client php-mysql \ +postgresql php-pgsql \ +postgresql-9.5-postgis-2.2 \ +git wget \ +curl apt-transport-https debconf-utils sudo + +# adding custom MS repository +RUN curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - +RUN curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list +RUN curl https://packages.microsoft.com/config/ubuntu/16.04/mssql-server-2017.list > /etc/apt/sources.list.d/mssql-server-2017.list + +# install SQL Server and tools +RUN apt-get update && apt-get -y install mssql-server +RUN ACCEPT_EULA=Y MSSQL_PID=Express MSSQL_SA_PASSWORD=sapwd123! /opt/mssql/bin/mssql-conf setup || true +RUN ACCEPT_EULA=Y apt-get install -y msodbcsql mssql-tools + +# install pdo_sqlsrv +RUN apt-get -y install php-pear build-essential unixodbc-dev php-dev +RUN pecl install pdo_sqlsrv-5.3.0 +RUN echo extension=pdo_sqlsrv.so > /etc/php/7.0/mods-available/pdo_sqlsrv.ini +RUN phpenmod pdo_sqlsrv + +# install locales +RUN apt-get -y install locales +RUN locale-gen en_US.UTF-8 +RUN update-locale LANG=en_US.UTF-8 + +# install run script +ADD run.sh /usr/sbin/docker-run +CMD docker-run diff --git a/docker/ubuntu16/run.sh b/docker/ubuntu16/run.sh new file mode 100755 index 0000000..a4dcd84 --- /dev/null +++ b/docker/ubuntu16/run.sh @@ -0,0 +1,73 @@ +#!/bin/bash +echo "================================================" +echo " Ubuntu 16.04 (PHP 7.0)" +echo "================================================" + +echo -n "[1/4] Starting MariaDB 10.0 ..... " +# make sure mysql can create socket and lock +mkdir /var/run/mysqld && chmod 777 /var/run/mysqld +# run mysql server +nohup mysqld > /root/mysql.log 2>&1 & +# wait for mysql to become available +while ! mysqladmin ping -hlocalhost >/dev/null 2>&1; do + sleep 1 +done +# create database and user on mysql +mysql -u root >/dev/null << 'EOF' +CREATE DATABASE `php-crud-api` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +CREATE USER 'php-crud-api'@'localhost' IDENTIFIED BY 'php-crud-api'; +GRANT ALL PRIVILEGES ON `php-crud-api`.* TO 'php-crud-api'@'localhost' WITH GRANT OPTION; +FLUSH PRIVILEGES; +EOF +echo "done" + +echo -n "[2/4] Starting PostgreSQL 9.5 ... " +# run postgres server +nohup su - -c "/usr/lib/postgresql/9.5/bin/postgres -D /etc/postgresql/9.5/main" postgres > /root/postgres.log 2>&1 & +# wait for postgres to become available +until su - -c "psql -U postgres -c '\q'" postgres >/dev/null 2>&1; do + sleep 1; +done +# create database and user on postgres +su - -c "psql -U postgres >/dev/null" postgres << 'EOF' +CREATE USER "php-crud-api" WITH PASSWORD 'php-crud-api'; +CREATE DATABASE "php-crud-api"; +GRANT ALL PRIVILEGES ON DATABASE "php-crud-api" to "php-crud-api"; +\c "php-crud-api"; +CREATE EXTENSION IF NOT EXISTS postgis; +\q +EOF +echo "done" + +echo -n "[3/4] Starting SQLServer 2017 ... " +# run sqlserver server +nohup /opt/mssql/bin/sqlservr --accept-eula > /root/mssql.log 2>&1 & +# create database and user on postgres +/opt/mssql-tools/bin/sqlcmd -l 30 -S localhost -U SA -P sapwd123! >/dev/null << 'EOF' +CREATE DATABASE [php-crud-api] +GO +CREATE LOGIN [php-crud-api] WITH PASSWORD=N'php-crud-api', DEFAULT_DATABASE=[php-crud-api], CHECK_EXPIRATION=OFF, CHECK_POLICY=OFF +GO +USE [php-crud-api] +GO +CREATE USER [php-crud-api] FOR LOGIN [php-crud-api] WITH DEFAULT_SCHEMA=[dbo] +exec sp_addrolemember 'db_owner', 'php-crud-api'; +GO +exit +EOF +echo "done" + +echo -n "[4/4] Cloning PHP-CRUD-API v2 ... " +# install software +if [ -d /php-crud-api ]; then + echo "skipped" +else + git clone --quiet https://github.com/mevdschee/php-crud-api.git + echo "done" +fi + +echo "------------------------------------------------" + +# run the tests +cd php-crud-api +php test.php diff --git a/docker/ubuntu18/Dockerfile b/docker/ubuntu18/Dockerfile new file mode 100644 index 0000000..e9c3a63 --- /dev/null +++ b/docker/ubuntu18/Dockerfile @@ -0,0 +1,21 @@ +FROM ubuntu:18.04 + +ARG DEBIAN_FRONTEND=noninteractive + +# install: php / mysql / postgres / sqlite / tools +RUN apt-get update && apt-get -y install \ +php-cli php-xml php-mbstring \ +mysql-server mysql-client php-mysql \ +postgresql php-pgsql \ +postgresql-10-postgis-2.4 \ +sqlite3 php-sqlite3 \ +git wget + +# install locales +RUN apt-get -y install locales +RUN locale-gen en_US.UTF-8 +RUN update-locale LANG=en_US.UTF-8 + +# install run script +ADD run.sh /usr/sbin/docker-run +CMD docker-run diff --git a/docker/ubuntu18/run.sh b/docker/ubuntu18/run.sh new file mode 100755 index 0000000..770c39c --- /dev/null +++ b/docker/ubuntu18/run.sh @@ -0,0 +1,60 @@ +#!/bin/bash +echo "================================================" +echo " Ubuntu 18.04 (PHP 7.2)" +echo "================================================" + +echo -n "[1/4] Starting MySQL 5.7 ........ " +# make sure mysql can create socket and lock +mkdir /var/run/mysqld && chmod 777 /var/run/mysqld +# run mysql server +nohup mysqld > /root/mysql.log 2>&1 & +# wait for mysql to become available +while ! mysqladmin ping -hlocalhost >/dev/null 2>&1; do + sleep 1 +done +# create database and user on mysql +mysql -u root >/dev/null << 'EOF' +CREATE DATABASE `php-crud-api` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +CREATE USER 'php-crud-api'@'localhost' IDENTIFIED BY 'php-crud-api'; +GRANT ALL PRIVILEGES ON `php-crud-api`.* TO 'php-crud-api'@'localhost' WITH GRANT OPTION; +FLUSH PRIVILEGES; +EOF +echo "done" + +echo -n "[2/4] Starting PostgreSQL 10.4 .. " +# ensure statistics can be written +mkdir /var/run/postgresql/10-main.pg_stat_tmp/ && chmod 777 /var/run/postgresql/10-main.pg_stat_tmp/ +# run postgres server +nohup su - -c "/usr/lib/postgresql/10/bin/postgres -D /etc/postgresql/10/main" postgres > /root/postgres.log 2>&1 & +# wait for postgres to become available +until su - -c "psql -U postgres -c '\q'" postgres >/dev/null 2>&1; do + sleep 1; +done +# create database and user on postgres +su - -c "psql -U postgres >/dev/null" postgres << 'EOF' +CREATE USER "php-crud-api" WITH PASSWORD 'php-crud-api'; +CREATE DATABASE "php-crud-api"; +GRANT ALL PRIVILEGES ON DATABASE "php-crud-api" to "php-crud-api"; +\c "php-crud-api"; +CREATE EXTENSION IF NOT EXISTS postgis; +\q +EOF +echo "done" + +echo -n "[3/4] Starting SQLServer 2017 ... " +echo "skipped" + +echo -n "[4/4] Cloning PHP-CRUD-API v2 ... " +# install software +if [ -d /php-crud-api ]; then + echo "skipped" +else + git clone --quiet https://github.com/mevdschee/php-crud-api.git + echo "done" +fi + +echo "------------------------------------------------" + +# run the tests +cd php-crud-api +php test.php diff --git a/docker/ubuntu20/Dockerfile b/docker/ubuntu20/Dockerfile new file mode 100644 index 0000000..b311134 --- /dev/null +++ b/docker/ubuntu20/Dockerfile @@ -0,0 +1,21 @@ +FROM ubuntu:20.04 + +ARG DEBIAN_FRONTEND=noninteractive + +# install: php / mysql / postgres / sqlite / tools +RUN apt-get update && apt-get -y install \ +php-cli php-xml php-mbstring \ +mysql-server mysql-client php-mysql \ +postgresql php-pgsql \ +postgresql-12-postgis-3 \ +sqlite3 php-sqlite3 \ +git wget + +# install locales +RUN apt-get -y install locales +RUN locale-gen en_US.UTF-8 +RUN update-locale LANG=en_US.UTF-8 + +# install run script +ADD run.sh /usr/sbin/docker-run +CMD docker-run diff --git a/docker/ubuntu20/run.sh b/docker/ubuntu20/run.sh new file mode 100755 index 0000000..3ca98b0 --- /dev/null +++ b/docker/ubuntu20/run.sh @@ -0,0 +1,60 @@ +#!/bin/bash +echo "================================================" +echo " Ubuntu 20.04 (PHP 7.4)" +echo "================================================" + +echo -n "[1/4] Starting MySQL 8.0 ........ " +# make sure mysql can create socket and lock +mkdir /var/run/mysqld && chmod 777 /var/run/mysqld +# run mysql server +nohup mysqld > /root/mysql.log 2>&1 & +# wait for mysql to become available +while ! mysqladmin ping -hlocalhost >/dev/null 2>&1; do + sleep 1 +done +# create database and user on mysql +mysql -u root >/dev/null << 'EOF' +CREATE DATABASE `php-crud-api` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +CREATE USER 'php-crud-api'@'localhost' IDENTIFIED WITH MYSQL_NATIVE_PASSWORD BY 'php-crud-api'; +GRANT ALL PRIVILEGES ON `php-crud-api`.* TO 'php-crud-api'@'localhost' WITH GRANT OPTION; +FLUSH PRIVILEGES; +EOF +echo "done" + +echo -n "[2/4] Starting PostgreSQL 12.2 .. " +# ensure statistics can be written +mkdir /var/run/postgresql/10-main.pg_stat_tmp/ && chmod 777 /var/run/postgresql/10-main.pg_stat_tmp/ +# run postgres server +nohup su - -c "/usr/lib/postgresql/12/bin/postgres -D /etc/postgresql/12/main" postgres > /root/postgres.log 2>&1 & +# wait for postgres to become available +until su - -c "psql -U postgres -c '\q'" postgres >/dev/null 2>&1; do + sleep 1; +done +# create database and user on postgres +su - -c "psql -U postgres >/dev/null" postgres << 'EOF' +CREATE USER "php-crud-api" WITH PASSWORD 'php-crud-api'; +CREATE DATABASE "php-crud-api"; +GRANT ALL PRIVILEGES ON DATABASE "php-crud-api" to "php-crud-api"; +\c "php-crud-api"; +CREATE EXTENSION IF NOT EXISTS postgis; +\q +EOF +echo "done" + +echo -n "[3/4] Starting SQLServer 2017 ... " +echo "skipped" + +echo -n "[4/4] Cloning PHP-CRUD-API v2 ... " +# install software +if [ -d /php-crud-api ]; then + echo "skipped" +else + git clone --quiet https://github.com/mevdschee/php-crud-api.git + echo "done" +fi + +echo "------------------------------------------------" + +# run the tests +cd php-crud-api +php test.php diff --git a/examples/clients/angular.html b/examples/clients/angular.html new file mode 100644 index 0000000..b14fb1f --- /dev/null +++ b/examples/clients/angular.html @@ -0,0 +1,24 @@ + + + + + + +
+
    +
  • {{ x.id + ', ' + x.content }}
  • +
+
+ + + diff --git a/examples/clients/angular2.html b/examples/clients/angular2.html new file mode 100644 index 0000000..bcd3c89 --- /dev/null +++ b/examples/clients/angular2.html @@ -0,0 +1,31 @@ + + + + + + + + +Loading... + + diff --git a/examples/clients/auth.php/vanilla.html b/examples/clients/auth.php/vanilla.html new file mode 100644 index 0000000..32b486f --- /dev/null +++ b/examples/clients/auth.php/vanilla.html @@ -0,0 +1,33 @@ + + + + + + +

+
+
diff --git a/examples/clients/auth0/vanilla.html b/examples/clients/auth0/vanilla.html
new file mode 100644
index 0000000..1277af3
--- /dev/null
+++ b/examples/clients/auth0/vanilla.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+

+ +

+

+
+
diff --git a/examples/clients/datatables.html b/examples/clients/datatables.html
new file mode 100644
index 0000000..dac0564
--- /dev/null
+++ b/examples/clients/datatables.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+    
+        
+            
+            
+            
+            
+        
+    
+    
+        
+            
+            
+            
+            
+        
+    
+
IDUsernameCategoryContent
IDUsernameCategoryContent
+ + + \ No newline at end of file diff --git a/examples/clients/firebase/vanilla-success.html b/examples/clients/firebase/vanilla-success.html new file mode 100644 index 0000000..824fd54 --- /dev/null +++ b/examples/clients/firebase/vanilla-success.html @@ -0,0 +1,97 @@ + + + + + + + + Success + + + + + + + + + + +

Firebase Login Success (or not)

+ +
+

+
+    

+
+
+
diff --git a/examples/clients/firebase/vanilla.html b/examples/clients/firebase/vanilla.html
new file mode 100644
index 0000000..9d75a5c
--- /dev/null
+++ b/examples/clients/firebase/vanilla.html
@@ -0,0 +1,68 @@
+
+
+
+    
+    
+    
+    
+        Firebase
+    
+
+    
+    
+    
+
+    
+
+    
+    
+
+    
+
+
+    

Firebase login

+ +
+ + diff --git a/examples/clients/handlebars.html b/examples/clients/handlebars.html new file mode 100644 index 0000000..a7f2ba4 --- /dev/null +++ b/examples/clients/handlebars.html @@ -0,0 +1,66 @@ + + + + + + + + +
Loading...
+ + diff --git a/examples/clients/knockout.html b/examples/clients/knockout.html new file mode 100644 index 0000000..d102a84 --- /dev/null +++ b/examples/clients/knockout.html @@ -0,0 +1,70 @@ + + + + + + + + +
Loading...
+ + diff --git a/examples/clients/leaflet/geojson-layer.js b/examples/clients/leaflet/geojson-layer.js new file mode 100644 index 0000000..36c74fc --- /dev/null +++ b/examples/clients/leaflet/geojson-layer.js @@ -0,0 +1,85 @@ +/* global L */ +(function() { + + L.GeoJSONLayer = L.GeoJSON.extend({ + + includes: L.Evented.prototype, + + url: null, + map: null, + + // + // Leaflet layer methods + // + initialize(url, options) { + this.url = url; + L.GeoJSON.prototype.initialize.call(this, [], options); + }, + + onAdd(map) { + L.GeoJSON.prototype.onAdd.call(this, map); + this.map = map; + map.on('moveend zoomend refresh', this._reloadMap, this); + this._reloadMap(); + }, + + onRemove(map) { + map.off('moveend zoomend refresh', this._reloadMap, this); + this.map = null; + L.GeoJSON.prototype.onRemove.call(this, map); + }, + + // + // Custom methods + // + _reloadMap: function() { + if (this.map) { + var url = this._expandUrl(this.url); + this._ajaxRequest('GET', url, false, this._updateLayers.bind(this)); + } + }, + + _expandUrl: function(template) { + var bbox = this.map.getBounds(); + var southWest = bbox.getSouthWest(); + var northEast = bbox.getNorthEast(); + var bboxStr = bbox.toBBoxString(); + var coords = { + lat1: southWest.lat, + lon1: southWest.lng, + lat2: northEast.lat, + lon2: northEast.lng, + bbox: bboxStr + }; + return L.Util.template(template, coords); + }, + + _ajaxRequest: function(method, url, data, callback) { + var request = new XMLHttpRequest(); + request.open(method, url, true); + request.onreadystatechange = function() { + if (request.readyState === 4 && request.status === 200) { + callback(JSON.parse(request.responseText)); + } + }; + if (data) { + request.setRequestHeader('Content-type', 'application/json'); + request.send(JSON.stringify(data)); + } else { + request.send(); + } + return request; + }, + + _updateLayers: function(geoData) { + this.clearLayers(); + this.addData(geoData); + } + + }); + + L.geoJSONLayer = function (url, options) { + return new L.GeoJSONLayer(url, options); + }; + +})(); \ No newline at end of file diff --git a/examples/clients/leaflet/geojson-tile-layer.js b/examples/clients/leaflet/geojson-tile-layer.js new file mode 100644 index 0000000..808ef8b --- /dev/null +++ b/examples/clients/leaflet/geojson-tile-layer.js @@ -0,0 +1,144 @@ +/* global L */ +(function() { + + L.GeoJSONTileLayer = L.GridLayer.extend({ + + includes: L.Evented.prototype, + + url: null, + map: null, + layer: null, + features: null, + cache: null, + + // + // Leaflet layer methods + // + initialize(url, options) { + this.url = url; + this.layer = new L.GeoJSON(null, options); + this.features = {}; + this.cache = {}; + L.GridLayer.prototype.initialize.call(this, options); + }, + + createTile(coords, done) { + var tile = L.DomUtil.create('div', 'leaflet-tile'); + tile.style['box-shadow'] = 'inset 0 0 2px #f00'; + var url = this._expandUrl(this.url, coords); + if (this.cache[coords]) { + done.call(this); + } else { + this._ajaxRequest('GET', url, false, this._updateCache.bind(this, done, coords)); + } + return tile; + }, + + onAdd(map) { + L.GridLayer.prototype.onAdd.call(this, map); + map.addLayer(this.layer); + this.map = map; + map.on('zoomanim', this._onZoomAnim.bind(this)); + this.on('loading', this._onLoading.bind(this)); + this.on('tileload', this._onTileLoad.bind(this)); + this.on('tileunload', this._onTileUnLoad.bind(this)); + }, + + onRemove(map) { + this.off('tileunload', this._onTileUnLoad.bind(this)); + this.off('tileload', this._onTileLoad.bind(this)); + this.off('loading', this._onLoading.bind(this)); + map.off('zoomanim', this._onZoomAnim.bind(this)); + this.map = null; + map.removeLayer(this.layer) + L.GridLayer.prototype.onRemove.call(this, map); + }, + + // + // Custom methods + // + _expandUrl: function(template, coords) { + return L.Util.template(template, coords); + }, + + _updateTiles: function() { + this.layer.clearLayers(); + this.features = {}; + for (var coords in this.cache) { + if (this.cache.hasOwnProperty(coords)) { + this._drawTile(coords); + } + } + }, + + _drawTile(coords) { + var geoData = this.cache[coords]; + if (geoData.type == 'FeatureCollection'){ + geoData = geoData.features; + } + for (var i=0;i this.options.maxZoom) || + (this.options.minZoom && zoom < this.options.minZoom)) { + this.map.removeLayer(this.layer); + this.cache = {}; + this.layer.clearLayers(); + } else { + this._updateTiles(); + this.map.addLayer(this.layer); + } + }, + + _onLoading: function (e) { + this._updateTiles(); + }, + + _onTileLoad: function (e) { + this._drawTile(e.coords); + }, + + _onTileUnLoad: function (e) { + delete this.cache[e.coords] + }, + + }); + + L.geoJSONTileLayer = function (url, options) { + return new L.GeoJSONTileLayer(url, options); + }; + +})(); \ No newline at end of file diff --git a/examples/clients/leaflet/vanilla.html b/examples/clients/leaflet/vanilla.html new file mode 100644 index 0000000..cb83e9b --- /dev/null +++ b/examples/clients/leaflet/vanilla.html @@ -0,0 +1,37 @@ + + + + Quick Start - Leaflet + + + + + + + + + + + + + +
+ + + + diff --git a/examples/clients/mustache.html b/examples/clients/mustache.html new file mode 100644 index 0000000..f496197 --- /dev/null +++ b/examples/clients/mustache.html @@ -0,0 +1,66 @@ + + + + + + + + +
Loading...
+ + diff --git a/examples/clients/qgis/geojson.png b/examples/clients/qgis/geojson.png new file mode 100644 index 0000000..a575896 Binary files /dev/null and b/examples/clients/qgis/geojson.png differ diff --git a/examples/clients/react.html b/examples/clients/react.html new file mode 100644 index 0000000..4e22d8e --- /dev/null +++ b/examples/clients/react.html @@ -0,0 +1,47 @@ + + + + + + + + +
Loading...
+ + diff --git a/examples/clients/upload/vanilla.html b/examples/clients/upload/vanilla.html new file mode 100644 index 0000000..e482300 --- /dev/null +++ b/examples/clients/upload/vanilla.html @@ -0,0 +1,73 @@ + + + + + + +
    +
    +
    + PNG preview...

    +

    + +
    + + diff --git a/examples/clients/vanilla.html b/examples/clients/vanilla.html new file mode 100644 index 0000000..677ddca --- /dev/null +++ b/examples/clients/vanilla.html @@ -0,0 +1,20 @@ + + + + + +
    
    +
    +
    +
    diff --git a/examples/clients/vue.html b/examples/clients/vue.html
    new file mode 100644
    index 0000000..b34dba7
    --- /dev/null
    +++ b/examples/clients/vue.html
    @@ -0,0 +1,250 @@
    +
    +
    +  
    +  
    +  Vue.js CRUD application
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +
    +
    +
    +
    +
    + +
    + +
    +
    + + + + + + + + + + + + + + + diff --git a/examples/clients/zepto.html b/examples/clients/zepto.html new file mode 100644 index 0000000..8c4eae3 --- /dev/null +++ b/examples/clients/zepto.html @@ -0,0 +1,76 @@ + + + + + + + +
    Loading...
    + + + diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..265ed82 --- /dev/null +++ b/examples/index.html @@ -0,0 +1,22 @@ + + + PHP-CRUD-API examples + + +

    Example clients

    + + + diff --git a/extras/core.php b/extras/core.php new file mode 100644 index 0000000..6850883 --- /dev/null +++ b/extras/core.php @@ -0,0 +1,66 @@ +0?',':'').'`'.$columns[$i].'`='; + $set.=($values[$i]===null?'NULL':'"'.$values[$i].'"'); +} + +// create SQL based on HTTP method +switch ($method) { + case 'GET': + $sql = "select * from `$table`".($key?" WHERE id=$key":''); break; + case 'PUT': + $sql = "update `$table` set $set where id=$key"; break; + case 'POST': + $sql = "insert into `$table` set $set"; break; + case 'DELETE': + $sql = "delete from `$table` where id=$key"; break; +} + +// execute SQL statement +$result = mysqli_query($link,$sql); + +// die if SQL statement failed +if (!$result) { + http_response_code(404); + die(mysqli_error($link)); +} + +// print results, insert id or affected row count +if ($method == 'GET') { + if (!$key) echo '['; + for ($i=0;$i0?',':'').json_encode(mysqli_fetch_object($result)); + } + if (!$key) echo ']'; +} elseif ($method == 'POST') { + echo mysqli_insert_id($link); +} else { + echo mysqli_affected_rows($link); +} + +// close mysql connection +mysqli_close($link); diff --git a/install.php b/install.php new file mode 100644 index 0000000..3c72171 --- /dev/null +++ b/install.php @@ -0,0 +1,11 @@ +getDriver(), + $config->getAddress(), + $config->getPort(), + $config->getDatabase(), + $config->getTables(), + $config->getUsername(), + $config->getPassword() + ); + $prefix = sprintf('phpcrudapi-%s-', substr(md5(__FILE__), 0, 8)); + $cache = CacheFactory::create($config->getCacheType(), $prefix, $config->getCachePath()); + $reflection = new ReflectionService($db, $cache, $config->getCacheTime()); + $responder = new JsonResponder(); + $router = new SimpleRouter($config->getBasePath(), $responder, $cache, $config->getCacheTime(), $config->getDebug()); + foreach ($config->getMiddlewares() as $middleware => $properties) { + switch ($middleware) { + case 'sslRedirect': + new SslRedirectMiddleware($router, $responder, $properties); + break; + case 'cors': + new CorsMiddleware($router, $responder, $properties, $config->getDebug()); + break; + case 'firewall': + new FirewallMiddleware($router, $responder, $properties); + break; + case 'basicAuth': + new BasicAuthMiddleware($router, $responder, $properties); + break; + case 'jwtAuth': + new JwtAuthMiddleware($router, $responder, $properties); + break; + case 'dbAuth': + new DbAuthMiddleware($router, $responder, $properties, $reflection, $db); + break; + case 'reconnect': + new ReconnectMiddleware($router, $responder, $properties, $reflection, $db); + break; + case 'validation': + new ValidationMiddleware($router, $responder, $properties, $reflection); + break; + case 'ipAddress': + new IpAddressMiddleware($router, $responder, $properties, $reflection); + break; + case 'sanitation': + new SanitationMiddleware($router, $responder, $properties, $reflection); + break; + case 'multiTenancy': + new MultiTenancyMiddleware($router, $responder, $properties, $reflection); + break; + case 'authorization': + new AuthorizationMiddleware($router, $responder, $properties, $reflection); + break; + case 'xsrf': + new XsrfMiddleware($router, $responder, $properties); + break; + case 'pageLimits': + new PageLimitsMiddleware($router, $responder, $properties, $reflection); + break; + case 'joinLimits': + new JoinLimitsMiddleware($router, $responder, $properties, $reflection); + break; + case 'customization': + new CustomizationMiddleware($router, $responder, $properties, $reflection); + break; + case 'xml': + new XmlMiddleware($router, $responder, $properties, $reflection); + break; + } + } + foreach ($config->getControllers() as $controller) { + switch ($controller) { + case 'records': + $records = new RecordService($db, $reflection); + new RecordController($router, $responder, $records); + break; + case 'columns': + $definition = new DefinitionService($db, $reflection); + new ColumnController($router, $responder, $reflection, $definition); + break; + case 'cache': + new CacheController($router, $responder, $cache); + break; + case 'openapi': + $openApi = new OpenApiService($reflection, $config->getOpenApiBase(), $config->getControllers(), $config->getCustomOpenApiBuilders()); + new OpenApiController($router, $responder, $openApi); + break; + case 'geojson': + $records = new RecordService($db, $reflection); + $geoJson = new GeoJsonService($reflection, $records); + new GeoJsonController($router, $responder, $geoJson); + break; + } + } + foreach ($config->getCustomControllers() as $className) { + if (class_exists($className)) { + $records = new RecordService($db, $reflection); + new $className($router, $responder, $records); + } + } + $this->router = $router; + $this->responder = $responder; + $this->debug = $config->getDebug(); + } + + private function parseBody(string $body) /*: ?object*/ + { + $first = substr($body, 0, 1); + if ($first == '[' || $first == '{') { + $object = json_decode($body); + $causeCode = json_last_error(); + if ($causeCode !== JSON_ERROR_NONE) { + $object = null; + } + } else { + parse_str($body, $input); + foreach ($input as $key => $value) { + if (substr($key, -9) == '__is_null') { + $input[substr($key, 0, -9)] = null; + unset($input[$key]); + } + } + $object = (object) $input; + } + return $object; + } + + private function addParsedBody(ServerRequestInterface $request): ServerRequestInterface + { + $parsedBody = $request->getParsedBody(); + if ($parsedBody) { + $request = $this->applyParsedBodyHack($request); + } else { + $body = $request->getBody(); + if ($body->isReadable()) { + if ($body->isSeekable()) { + $body->rewind(); + } + $contents = $body->getContents(); + if ($body->isSeekable()) { + $body->rewind(); + } + if ($contents) { + $parsedBody = $this->parseBody($contents); + $request = $request->withParsedBody($parsedBody); + } + } + } + return $request; + } + + private function applyParsedBodyHack(ServerRequestInterface $request): ServerRequestInterface + { + $parsedBody = $request->getParsedBody(); + if (is_array($parsedBody)) { // is it really? + $contents = json_encode($parsedBody); + $parsedBody = $this->parseBody($contents); + $request = $request->withParsedBody($parsedBody); + } + return $request; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->router->route($this->addParsedBody($request)); + } +} diff --git a/src/Tqdev/PhpCrudApi/Cache/Cache.php b/src/Tqdev/PhpCrudApi/Cache/Cache.php new file mode 100644 index 0000000..286a05c --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Cache/Cache.php @@ -0,0 +1,10 @@ +prefix = $prefix; + if ($config == '') { + $address = 'localhost'; + $port = 11211; + } elseif (strpos($config, ':') === false) { + $address = $config; + $port = 11211; + } else { + list($address, $port) = explode(':', $config); + } + $this->memcache = $this->create(); + $this->memcache->addServer($address, $port); + } + + protected function create() /*: \Memcache*/ + { + return new \Memcache(); + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return $this->memcache->set($this->prefix . $key, $value, 0, $ttl); + } + + public function get(string $key): string + { + return $this->memcache->get($this->prefix . $key) ?: ''; + } + + public function clear(): bool + { + return $this->memcache->flush(); + } +} diff --git a/src/Tqdev/PhpCrudApi/Cache/MemcachedCache.php b/src/Tqdev/PhpCrudApi/Cache/MemcachedCache.php new file mode 100644 index 0000000..06f9fe9 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Cache/MemcachedCache.php @@ -0,0 +1,16 @@ +memcache->set($this->prefix . $key, $value, $ttl); + } +} diff --git a/src/Tqdev/PhpCrudApi/Cache/NoCache.php b/src/Tqdev/PhpCrudApi/Cache/NoCache.php new file mode 100644 index 0000000..2f6c62e --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Cache/NoCache.php @@ -0,0 +1,25 @@ +prefix = $prefix; + if ($config == '') { + $config = '127.0.0.1'; + } + $params = explode(':', $config, 6); + if (isset($params[3])) { + $params[3] = null; + } + $this->redis = new \Redis(); + call_user_func_array(array($this->redis, 'pconnect'), $params); + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + return $this->redis->set($this->prefix . $key, $value, $ttl); + } + + public function get(string $key): string + { + return $this->redis->get($this->prefix . $key) ?: ''; + } + + public function clear(): bool + { + return $this->redis->flushDb(); + } +} diff --git a/src/Tqdev/PhpCrudApi/Cache/TempFileCache.php b/src/Tqdev/PhpCrudApi/Cache/TempFileCache.php new file mode 100644 index 0000000..1d4100b --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Cache/TempFileCache.php @@ -0,0 +1,149 @@ +segments = []; + $s = DIRECTORY_SEPARATOR; + $ps = PATH_SEPARATOR; + if ($config == '') { + $this->path = sys_get_temp_dir() . $s . $prefix . self::SUFFIX; + } elseif (strpos($config, $ps) === false) { + $this->path = $config; + } else { + list($path, $segments) = explode($ps, $config); + $this->path = $path; + $this->segments = explode(',', $segments); + } + if (file_exists($this->path) && is_dir($this->path)) { + $this->clean($this->path, array_filter($this->segments), strlen(md5('')), false); + } + } + + private function getFileName(string $key): string + { + $s = DIRECTORY_SEPARATOR; + $md5 = md5($key); + $filename = rtrim($this->path, $s) . $s; + $i = 0; + foreach ($this->segments as $segment) { + $filename .= substr($md5, $i, $segment) . $s; + $i += $segment; + } + $filename .= substr($md5, $i); + return $filename; + } + + public function set(string $key, string $value, int $ttl = 0): bool + { + $filename = $this->getFileName($key); + $dirname = dirname($filename); + if (!file_exists($dirname)) { + if (!mkdir($dirname, 0755, true)) { + return false; + } + } + $string = $ttl . '|' . $value; + return $this->filePutContents($filename, $string) !== false; + } + + private function filePutContents($filename, $string) + { + return file_put_contents($filename, $string, LOCK_EX); + } + + private function fileGetContents($filename) + { + $file = fopen($filename, 'rb'); + if ($file === false) { + return false; + } + $lock = flock($file, LOCK_SH); + if (!$lock) { + fclose($file); + return false; + } + $string = ''; + while (!feof($file)) { + $string .= fread($file, 8192); + } + flock($file, LOCK_UN); + fclose($file); + return $string; + } + + private function getString($filename): string + { + $data = $this->fileGetContents($filename); + if ($data === false) { + return ''; + } + if (strpos($data, '|') === false) { + return ''; + } + list($ttl, $string) = explode('|', $data, 2); + if ($ttl > 0 && time() - filemtime($filename) > $ttl) { + return ''; + } + return $string; + } + + public function get(string $key): string + { + $filename = $this->getFileName($key); + if (!file_exists($filename)) { + return ''; + } + $string = $this->getString($filename); + if ($string == null) { + return ''; + } + return $string; + } + + private function clean(string $path, array $segments, int $len, bool $all) /*: void*/ + { + $entries = scandir($path); + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $filename = $path . DIRECTORY_SEPARATOR . $entry; + if (count($segments) == 0) { + if (strlen($entry) != $len) { + continue; + } + if (file_exists($filename) && is_file($filename)) { + if ($all || $this->getString($filename) == null) { + @unlink($filename); + } + } + } else { + if (strlen($entry) != $segments[0]) { + continue; + } + if (file_exists($filename) && is_dir($filename)) { + $this->clean($filename, array_slice($segments, 1), $len - $segments[0], $all); + @rmdir($filename); + } + } + } + } + + public function clear(): bool + { + if (!file_exists($this->path) || !is_dir($this->path)) { + return false; + } + $this->clean($this->path, array_filter($this->segments), strlen(md5('')), true); + return true; + } +} diff --git a/src/Tqdev/PhpCrudApi/Column/DefinitionService.php b/src/Tqdev/PhpCrudApi/Column/DefinitionService.php new file mode 100644 index 0000000..0b96fac --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Column/DefinitionService.php @@ -0,0 +1,159 @@ +db = $db; + $this->reflection = $reflection; + } + + public function updateTable(string $tableName, /* object */ $changes): bool + { + $table = $this->reflection->getTable($tableName); + $newTable = ReflectedTable::fromJson((object) array_merge((array) $table->jsonSerialize(), (array) $changes)); + if ($table->getName() != $newTable->getName()) { + if (!$this->db->definition()->renameTable($table->getName(), $newTable->getName())) { + return false; + } + } + return true; + } + + public function updateColumn(string $tableName, string $columnName, /* object */ $changes): bool + { + $table = $this->reflection->getTable($tableName); + $column = $table->getColumn($columnName); + + // remove constraints on other column + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + if ($newColumn->getPk() != $column->getPk() && $table->hasPk()) { + $oldColumn = $table->getPk(); + if ($oldColumn->getName() != $columnName) { + $oldColumn->setPk(false); + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $oldColumn->getName(), $oldColumn)) { + return false; + } + } + } + + // remove constraints + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), ['pk' => false, 'fk' => false])); + if ($newColumn->getPk() != $column->getPk() && !$newColumn->getPk()) { + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getFk() != $column->getFk() && !$newColumn->getFk()) { + if (!$this->db->definition()->removeColumnForeignKey($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + + // name and type + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + $newColumn->setPk(false); + $newColumn->setFk(''); + if ($newColumn->getName() != $column->getName()) { + if (!$this->db->definition()->renameColumn($table->getName(), $column->getName(), $newColumn)) { + return false; + } + } + if ( + $newColumn->getType() != $column->getType() || + $newColumn->getLength() != $column->getLength() || + $newColumn->getPrecision() != $column->getPrecision() || + $newColumn->getScale() != $column->getScale() + ) { + if (!$this->db->definition()->retypeColumn($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getNullable() != $column->getNullable()) { + if (!$this->db->definition()->setColumnNullable($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + + // add constraints + $newColumn = ReflectedColumn::fromJson((object) array_merge((array) $column->jsonSerialize(), (array) $changes)); + if ($newColumn->getFk()) { + if (!$this->db->definition()->addColumnForeignKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getPk()) { + if (!$this->db->definition()->addColumnPrimaryKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + return true; + } + + public function addTable(/* object */$definition) + { + $newTable = ReflectedTable::fromJson($definition); + if (!$this->db->definition()->addTable($newTable)) { + return false; + } + return true; + } + + public function addColumn(string $tableName, /* object */ $definition) + { + $newColumn = ReflectedColumn::fromJson($definition); + if (!$this->db->definition()->addColumn($tableName, $newColumn)) { + return false; + } + if ($newColumn->getFk()) { + if (!$this->db->definition()->addColumnForeignKey($tableName, $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getPk()) { + if (!$this->db->definition()->addColumnPrimaryKey($tableName, $newColumn->getName(), $newColumn)) { + return false; + } + } + return true; + } + + public function removeTable(string $tableName) + { + if (!$this->db->definition()->removeTable($tableName)) { + return false; + } + return true; + } + + public function removeColumn(string $tableName, string $columnName) + { + $table = $this->reflection->getTable($tableName); + $newColumn = $table->getColumn($columnName); + if ($newColumn->getPk()) { + $newColumn->setPk(false); + if (!$this->db->definition()->removeColumnPrimaryKey($table->getName(), $newColumn->getName(), $newColumn)) { + return false; + } + } + if ($newColumn->getFk()) { + $newColumn->setFk(""); + if (!$this->db->definition()->removeColumnForeignKey($tableName, $columnName, $newColumn)) { + return false; + } + } + if (!$this->db->definition()->removeColumn($tableName, $columnName)) { + return false; + } + return true; + } +} diff --git a/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php b/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php new file mode 100644 index 0000000..01486f3 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php @@ -0,0 +1,213 @@ +name = $name; + $this->type = $type; + $this->length = $length; + $this->precision = $precision; + $this->scale = $scale; + $this->nullable = $nullable; + $this->pk = $pk; + $this->fk = $fk; + $this->sanitize(); + } + + private static function parseColumnType(string $columnType, int &$length, int &$precision, int &$scale) /*: void*/ + { + if (!$columnType) { + return; + } + $pos = strpos($columnType, '('); + if ($pos) { + $dataSize = rtrim(substr($columnType, $pos + 1), ')'); + if ($length) { + $length = (int) $dataSize; + } else { + $pos = strpos($dataSize, ','); + if ($pos) { + $precision = (int) substr($dataSize, 0, $pos); + $scale = (int) substr($dataSize, $pos + 1); + } else { + $precision = (int) $dataSize; + $scale = 0; + } + } + } + } + + private static function getDataSize(int $length, int $precision, int $scale): string + { + $dataSize = ''; + if ($length) { + $dataSize = $length; + } elseif ($precision) { + if ($scale) { + $dataSize = $precision . ',' . $scale; + } else { + $dataSize = $precision; + } + } + return $dataSize; + } + + public static function fromReflection(GenericReflection $reflection, array $columnResult): ReflectedColumn + { + $name = $columnResult['COLUMN_NAME']; + $dataType = $columnResult['DATA_TYPE']; + $length = (int) $columnResult['CHARACTER_MAXIMUM_LENGTH']; + $precision = (int) $columnResult['NUMERIC_PRECISION']; + $scale = (int) $columnResult['NUMERIC_SCALE']; + $columnType = $columnResult['COLUMN_TYPE']; + self::parseColumnType($columnType, $length, $precision, $scale); + $dataSize = self::getDataSize($length, $precision, $scale); + $type = $reflection->toJdbcType($dataType, $dataSize); + $nullable = in_array(strtoupper($columnResult['IS_NULLABLE']), ['TRUE', 'YES', 'T', 'Y', '1']); + $pk = false; + $fk = ''; + return new ReflectedColumn($name, $type, $length, $precision, $scale, $nullable, $pk, $fk); + } + + public static function fromJson(/* object */$json): ReflectedColumn + { + $name = $json->name; + $type = $json->type; + $length = isset($json->length) ? (int) $json->length : 0; + $precision = isset($json->precision) ? (int) $json->precision : 0; + $scale = isset($json->scale) ? (int) $json->scale : 0; + $nullable = isset($json->nullable) ? (bool) $json->nullable : false; + $pk = isset($json->pk) ? (bool) $json->pk : false; + $fk = isset($json->fk) ? $json->fk : ''; + return new ReflectedColumn($name, $type, $length, $precision, $scale, $nullable, $pk, $fk); + } + + private function sanitize() + { + $this->length = $this->hasLength() ? $this->getLength() : 0; + $this->precision = $this->hasPrecision() ? $this->getPrecision() : 0; + $this->scale = $this->hasScale() ? $this->getScale() : 0; + } + + public function getName(): string + { + return $this->name; + } + + public function getNullable(): bool + { + return $this->nullable; + } + + public function getType(): string + { + return $this->type; + } + + public function getLength(): int + { + return $this->length ?: self::DEFAULT_LENGTH; + } + + public function getPrecision(): int + { + return $this->precision ?: self::DEFAULT_PRECISION; + } + + public function getScale(): int + { + return $this->scale ?: self::DEFAULT_SCALE; + } + + public function hasLength(): bool + { + return in_array($this->type, ['varchar', 'varbinary']); + } + + public function hasPrecision(): bool + { + return $this->type == 'decimal'; + } + + public function hasScale(): bool + { + return $this->type == 'decimal'; + } + + public function isBinary(): bool + { + return in_array($this->type, ['blob', 'varbinary']); + } + + public function isBoolean(): bool + { + return $this->type == 'boolean'; + } + + public function isGeometry(): bool + { + return $this->type == 'geometry'; + } + + public function isInteger(): bool + { + return in_array($this->type, ['integer', 'bigint', 'smallint', 'tinyint']); + } + + public function setPk($value) /*: void*/ + { + $this->pk = $value; + } + + public function getPk(): bool + { + return $this->pk; + } + + public function setFk($value) /*: void*/ + { + $this->fk = $value; + } + + public function getFk(): string + { + return $this->fk; + } + + public function serialize() + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'length' => $this->length, + 'precision' => $this->precision, + 'scale' => $this->scale, + 'nullable' => $this->nullable, + 'pk' => $this->pk, + 'fk' => $this->fk, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize()); + } +} diff --git a/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedDatabase.php b/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedDatabase.php new file mode 100644 index 0000000..8d861ae --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedDatabase.php @@ -0,0 +1,71 @@ +tableTypes = $tableTypes; + } + + public static function fromReflection(GenericReflection $reflection): ReflectedDatabase + { + $tableTypes = []; + foreach ($reflection->getTables() as $table) { + $tableName = $table['TABLE_NAME']; + $tableType = $table['TABLE_TYPE']; + if (in_array($tableName, $reflection->getIgnoredTables())) { + continue; + } + $tableTypes[$tableName] = $tableType; + } + return new ReflectedDatabase($tableTypes); + } + + public static function fromJson(/* object */$json): ReflectedDatabase + { + $tableTypes = (array) $json->tables; + return new ReflectedDatabase($tableTypes); + } + + public function hasTable(string $tableName): bool + { + return isset($this->tableTypes[$tableName]); + } + + public function getType(string $tableName): string + { + return isset($this->tableTypes[$tableName]) ? $this->tableTypes[$tableName] : ''; + } + + public function getTableNames(): array + { + return array_keys($this->tableTypes); + } + + public function removeTable(string $tableName): bool + { + if (!isset($this->tableTypes[$tableName])) { + return false; + } + unset($this->tableTypes[$tableName]); + return true; + } + + public function serialize() + { + return [ + 'tables' => $this->tableTypes, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } +} diff --git a/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedTable.php b/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedTable.php new file mode 100644 index 0000000..2b57e98 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedTable.php @@ -0,0 +1,169 @@ +name = $name; + $this->type = $type; + // set columns + $this->columns = []; + foreach ($columns as $column) { + $columnName = $column->getName(); + $this->columns[$columnName] = $column; + } + // set primary key + $this->pk = null; + foreach ($columns as $column) { + if ($column->getPk() == true) { + $this->pk = $column; + } + } + // set foreign keys + $this->fks = []; + foreach ($columns as $column) { + $columnName = $column->getName(); + $referencedTableName = $column->getFk(); + if ($referencedTableName != '') { + $this->fks[$columnName] = $referencedTableName; + } + } + } + + public static function fromReflection(GenericReflection $reflection, string $name, string $type): ReflectedTable + { + // set columns + $columns = []; + foreach ($reflection->getTableColumns($name, $type) as $tableColumn) { + $column = ReflectedColumn::fromReflection($reflection, $tableColumn); + $columns[$column->getName()] = $column; + } + // set primary key + $columnName = false; + if ($type == 'view') { + $columnName = 'id'; + } else { + $columnNames = $reflection->getTablePrimaryKeys($name); + if (count($columnNames) == 1) { + $columnName = $columnNames[0]; + } + } + if ($columnName && isset($columns[$columnName])) { + $pk = $columns[$columnName]; + $pk->setPk(true); + } + // set foreign keys + if ($type == 'view') { + $tables = $reflection->getTables(); + foreach ($columns as $columnName => $column) { + if (substr($columnName, -3) == '_id') { + foreach ($tables as $table) { + $tableName = $table['TABLE_NAME']; + $suffix = $tableName . '_id'; + if (substr($columnName, -1 * strlen($suffix)) == $suffix) { + $column->setFk($tableName); + } + } + } + } + } else { + $fks = $reflection->getTableForeignKeys($name); + foreach ($fks as $columnName => $table) { + $columns[$columnName]->setFk($table); + } + } + return new ReflectedTable($name, $type, array_values($columns)); + } + + public static function fromJson( /* object */$json): ReflectedTable + { + $name = $json->name; + $type = isset($json->type) ? $json->type : 'table'; + $columns = []; + if (isset($json->columns) && is_array($json->columns)) { + foreach ($json->columns as $column) { + $columns[] = ReflectedColumn::fromJson($column); + } + } + return new ReflectedTable($name, $type, $columns); + } + + public function hasColumn(string $columnName): bool + { + return isset($this->columns[$columnName]); + } + + public function hasPk(): bool + { + return $this->pk != null; + } + + public function getPk() /*: ?ReflectedColumn */ + { + return $this->pk; + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): string + { + return $this->type; + } + + public function getColumnNames(): array + { + return array_keys($this->columns); + } + + public function getColumn($columnName): ReflectedColumn + { + return $this->columns[$columnName]; + } + + public function getFksTo(string $tableName): array + { + $columns = array(); + foreach ($this->fks as $columnName => $referencedTableName) { + if ($tableName == $referencedTableName && !is_null($this->columns[$columnName])) { + $columns[] = $this->columns[$columnName]; + } + } + return $columns; + } + + public function removeColumn(string $columnName): bool + { + if (!isset($this->columns[$columnName])) { + return false; + } + unset($this->columns[$columnName]); + return true; + } + + public function serialize() + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'columns' => array_values($this->columns), + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } +} diff --git a/src/Tqdev/PhpCrudApi/Column/ReflectionService.php b/src/Tqdev/PhpCrudApi/Column/ReflectionService.php new file mode 100644 index 0000000..be2d5f7 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Column/ReflectionService.php @@ -0,0 +1,103 @@ +db = $db; + $this->cache = $cache; + $this->ttl = $ttl; + $this->database = null; + $this->tables = []; + } + + private function database(): ReflectedDatabase + { + if ($this->database) { + return $this->database; + } + $this->database = $this->loadDatabase(true); + return $this->database; + } + + private function loadDatabase(bool $useCache): ReflectedDatabase + { + $key = sprintf('%s-ReflectedDatabase', $this->db->getCacheKey()); + $data = $useCache ? $this->cache->get($key) : ''; + if ($data != '') { + $database = ReflectedDatabase::fromJson(json_decode(gzuncompress($data))); + } else { + $database = ReflectedDatabase::fromReflection($this->db->reflection()); + $data = gzcompress(json_encode($database, JSON_UNESCAPED_UNICODE)); + $this->cache->set($key, $data, $this->ttl); + } + return $database; + } + + private function loadTable(string $tableName, bool $useCache): ReflectedTable + { + $key = sprintf('%s-ReflectedTable(%s)', $this->db->getCacheKey(), $tableName); + $data = $useCache ? $this->cache->get($key) : ''; + if ($data != '') { + $table = ReflectedTable::fromJson(json_decode(gzuncompress($data))); + } else { + $tableType = $this->database()->getType($tableName); + $table = ReflectedTable::fromReflection($this->db->reflection(), $tableName, $tableType); + $data = gzcompress(json_encode($table, JSON_UNESCAPED_UNICODE)); + $this->cache->set($key, $data, $this->ttl); + } + return $table; + } + + public function refreshTables() + { + $this->database = $this->loadDatabase(false); + } + + public function refreshTable(string $tableName) + { + $this->tables[$tableName] = $this->loadTable($tableName, false); + } + + public function hasTable(string $tableName): bool + { + return $this->database()->hasTable($tableName); + } + + public function getType(string $tableName): string + { + return $this->database()->getType($tableName); + } + + public function getTable(string $tableName): ReflectedTable + { + if (!isset($this->tables[$tableName])) { + $this->tables[$tableName] = $this->loadTable($tableName, true); + } + return $this->tables[$tableName]; + } + + public function getTableNames(): array + { + return $this->database()->getTableNames(); + } + + public function removeTable(string $tableName): bool + { + unset($this->tables[$tableName]); + return $this->database()->removeTable($tableName); + } +} diff --git a/src/Tqdev/PhpCrudApi/Config.php b/src/Tqdev/PhpCrudApi/Config.php new file mode 100644 index 0000000..6fc9670 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Config.php @@ -0,0 +1,205 @@ + null, + 'address' => 'localhost', + 'port' => null, + 'username' => null, + 'password' => null, + 'database' => null, + 'tables' => '', + 'middlewares' => 'cors,errors', + 'controllers' => 'records,geojson,openapi', + 'customControllers' => '', + 'customOpenApiBuilders' => '', + 'cacheType' => 'TempFile', + 'cachePath' => '', + 'cacheTime' => 10, + 'debug' => false, + 'basePath' => '', + 'openApiBase' => '{"info":{"title":"PHP-CRUD-API","version":"1.0.0"}}', + ]; + + private function getDefaultDriver(array $values): string + { + if (isset($values['driver'])) { + return $values['driver']; + } + return 'mysql'; + } + + private function getDefaultPort(string $driver): int + { + switch ($driver) { + case 'mysql': + return 3306; + case 'pgsql': + return 5432; + case 'sqlsrv': + return 1433; + case 'sqlite': + return 0; + } + } + + private function getDefaultAddress(string $driver): string + { + switch ($driver) { + case 'mysql': + return 'localhost'; + case 'pgsql': + return 'localhost'; + case 'sqlsrv': + return 'localhost'; + case 'sqlite': + return 'data.db'; + } + } + + private function getDriverDefaults(string $driver): array + { + return [ + 'driver' => $driver, + 'address' => $this->getDefaultAddress($driver), + 'port' => $this->getDefaultPort($driver), + ]; + } + + private function applyEnvironmentVariables(array $values): array + { + $newValues = array(); + foreach ($values as $key => $value) { + $environmentKey = 'PHP_CRUD_API_' . strtoupper(preg_replace('/(?getDefaultDriver($values); + $defaults = $this->getDriverDefaults($driver); + $newValues = array_merge($this->values, $defaults, $values); + $newValues = $this->parseMiddlewares($newValues); + $diff = array_diff_key($newValues, $this->values); + if (!empty($diff)) { + $key = array_keys($diff)[0]; + throw new \Exception("Config has invalid value '$key'"); + } + $newValues = $this->applyEnvironmentVariables($newValues); + $this->values = $newValues; + } + + private function parseMiddlewares(array $values): array + { + $newValues = array(); + $properties = array(); + $middlewares = array_map('trim', explode(',', $values['middlewares'])); + foreach ($middlewares as $middleware) { + $properties[$middleware] = []; + } + foreach ($values as $key => $value) { + if (strpos($key, '.') === false) { + $newValues[$key] = $value; + } else { + list($middleware, $key2) = explode('.', $key, 2); + if (isset($properties[$middleware])) { + $properties[$middleware][$key2] = $value; + } else { + throw new \Exception("Config has invalid value '$key'"); + } + } + } + $newValues['middlewares'] = array_reverse($properties, true); + return $newValues; + } + + public function getDriver(): string + { + return $this->values['driver']; + } + + public function getAddress(): string + { + return $this->values['address']; + } + + public function getPort(): int + { + return $this->values['port']; + } + + public function getUsername(): string + { + return $this->values['username']; + } + + public function getPassword(): string + { + return $this->values['password']; + } + + public function getDatabase(): string + { + return $this->values['database']; + } + + public function getTables(): array + { + return array_filter(array_map('trim', explode(',', $this->values['tables']))); + } + + public function getMiddlewares(): array + { + return $this->values['middlewares']; + } + + public function getControllers(): array + { + return array_filter(array_map('trim', explode(',', $this->values['controllers']))); + } + + public function getCustomControllers(): array + { + return array_filter(array_map('trim', explode(',', $this->values['customControllers']))); + } + + public function getCustomOpenApiBuilders(): array + { + return array_filter(array_map('trim', explode(',', $this->values['customOpenApiBuilders']))); + } + + public function getCacheType(): string + { + return $this->values['cacheType']; + } + + public function getCachePath(): string + { + return $this->values['cachePath']; + } + + public function getCacheTime(): int + { + return $this->values['cacheTime']; + } + + public function getDebug(): bool + { + return $this->values['debug']; + } + + public function getBasePath(): string + { + return $this->values['basePath']; + } + + public function getOpenApiBase(): array + { + return json_decode($this->values['openApiBase'], true); + } +} diff --git a/src/Tqdev/PhpCrudApi/Controller/CacheController.php b/src/Tqdev/PhpCrudApi/Controller/CacheController.php new file mode 100644 index 0000000..88b57bc --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Controller/CacheController.php @@ -0,0 +1,26 @@ +register('GET', '/cache/clear', array($this, 'clear')); + $this->cache = $cache; + $this->responder = $responder; + } + + public function clear(ServerRequestInterface $request): ResponseInterface + { + return $this->responder->success($this->cache->clear()); + } +} diff --git a/src/Tqdev/PhpCrudApi/Controller/ColumnController.php b/src/Tqdev/PhpCrudApi/Controller/ColumnController.php new file mode 100644 index 0000000..ae865da --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Controller/ColumnController.php @@ -0,0 +1,162 @@ +register('GET', '/columns', array($this, 'getDatabase')); + $router->register('GET', '/columns/*', array($this, 'getTable')); + $router->register('GET', '/columns/*/*', array($this, 'getColumn')); + $router->register('PUT', '/columns/*', array($this, 'updateTable')); + $router->register('PUT', '/columns/*/*', array($this, 'updateColumn')); + $router->register('POST', '/columns', array($this, 'addTable')); + $router->register('POST', '/columns/*', array($this, 'addColumn')); + $router->register('DELETE', '/columns/*', array($this, 'removeTable')); + $router->register('DELETE', '/columns/*/*', array($this, 'removeColumn')); + $this->responder = $responder; + $this->reflection = $reflection; + $this->definition = $definition; + } + + public function getDatabase(ServerRequestInterface $request): ResponseInterface + { + $tables = []; + foreach ($this->reflection->getTableNames() as $table) { + $tables[] = $this->reflection->getTable($table); + } + $database = ['tables' => $tables]; + return $this->responder->success($database); + } + + public function getTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + return $this->responder->success($table); + } + + public function getColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $column = $table->getColumn($columnName); + return $this->responder->success($column); + } + + public function updateTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $success = $this->definition->updateTable($tableName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function updateColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $success = $this->definition->updateColumn($tableName, $columnName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } + + public function addTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = $request->getParsedBody()->name; + if ($this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_ALREADY_EXISTS, $tableName); + } + $success = $this->definition->addTable($request->getParsedBody()); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function addColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $columnName = $request->getParsedBody()->name; + $table = $this->reflection->getTable($tableName); + if ($table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_ALREADY_EXISTS, $columnName); + } + $success = $this->definition->addColumn($tableName, $request->getParsedBody()); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } + + public function removeTable(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $success = $this->definition->removeTable($tableName); + if ($success) { + $this->reflection->refreshTables(); + } + return $this->responder->success($success); + } + + public function removeColumn(ServerRequestInterface $request): ResponseInterface + { + $tableName = RequestUtils::getPathSegment($request, 2); + $columnName = RequestUtils::getPathSegment($request, 3); + if (!$this->reflection->hasTable($tableName)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $tableName); + } + $table = $this->reflection->getTable($tableName); + if (!$table->hasColumn($columnName)) { + return $this->responder->error(ErrorCode::COLUMN_NOT_FOUND, $columnName); + } + $success = $this->definition->removeColumn($tableName, $columnName); + if ($success) { + $this->reflection->refreshTable($tableName); + } + return $this->responder->success($success); + } +} diff --git a/src/Tqdev/PhpCrudApi/Controller/GeoJsonController.php b/src/Tqdev/PhpCrudApi/Controller/GeoJsonController.php new file mode 100644 index 0000000..c3ef586 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Controller/GeoJsonController.php @@ -0,0 +1,61 @@ +register('GET', '/geojson/*', array($this, '_list')); + $router->register('GET', '/geojson/*/*', array($this, 'read')); + $this->service = $service; + $this->responder = $responder; + } + + public function _list(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + $params = RequestUtils::getParams($request); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + return $this->responder->success($this->service->_list($table, $params)); + } + + public function read(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + if (strpos($id, ',') !== false) { + $ids = explode(',', $id); + $result = (object) array('type' => 'FeatureCollection', 'features' => array()); + for ($i = 0; $i < count($ids); $i++) { + array_push($result->features, $this->service->read($table, $ids[$i], $params)); + } + return $this->responder->success($result); + } else { + $response = $this->service->read($table, $id, $params); + if ($response === null) { + return $this->responder->error(ErrorCode::RECORD_NOT_FOUND, $id); + } + return $this->responder->success($response); + } + } +} diff --git a/src/Tqdev/PhpCrudApi/Controller/JsonResponder.php b/src/Tqdev/PhpCrudApi/Controller/JsonResponder.php new file mode 100644 index 0000000..d0d0937 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Controller/JsonResponder.php @@ -0,0 +1,24 @@ +getStatus(); + $document = new ErrorDocument($errorCode, $argument, $details); + return ResponseFactory::fromObject($status, $document); + } + + public function success($result): ResponseInterface + { + return ResponseFactory::fromObject(ResponseFactory::OK, $result); + } +} diff --git a/src/Tqdev/PhpCrudApi/Controller/OpenApiController.php b/src/Tqdev/PhpCrudApi/Controller/OpenApiController.php new file mode 100644 index 0000000..1b7a114 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Controller/OpenApiController.php @@ -0,0 +1,26 @@ +register('GET', '/openapi', array($this, 'openapi')); + $this->openApi = $openApi; + $this->responder = $responder; + } + + public function openapi(ServerRequestInterface $request): ResponseInterface + { + return $this->responder->success($this->openApi->get()); + } +} diff --git a/src/Tqdev/PhpCrudApi/Controller/RecordController.php b/src/Tqdev/PhpCrudApi/Controller/RecordController.php new file mode 100644 index 0000000..7c5718f --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Controller/RecordController.php @@ -0,0 +1,176 @@ +register('GET', '/records/*', array($this, '_list')); + $router->register('POST', '/records/*', array($this, 'create')); + $router->register('GET', '/records/*/*', array($this, 'read')); + $router->register('PUT', '/records/*/*', array($this, 'update')); + $router->register('DELETE', '/records/*/*', array($this, 'delete')); + $router->register('PATCH', '/records/*/*', array($this, 'increment')); + $this->service = $service; + $this->responder = $responder; + } + + public function _list(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + $params = RequestUtils::getParams($request); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + return $this->responder->success($this->service->_list($table, $params)); + } + + public function read(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + if (strpos($id, ',') !== false) { + $ids = explode(',', $id); + $result = []; + for ($i = 0; $i < count($ids); $i++) { + array_push($result, $this->service->read($table, $ids[$i], $params)); + } + return $this->responder->success($result); + } else { + $response = $this->service->read($table, $id, $params); + if ($response === null) { + return $this->responder->error(ErrorCode::RECORD_NOT_FOUND, $id); + } + return $this->responder->success($response); + } + } + + public function create(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $params = RequestUtils::getParams($request); + if (is_array($record)) { + $result = array(); + foreach ($record as $r) { + $result[] = $this->service->create($table, $r, $params); + } + return $this->responder->success($result); + } else { + return $this->responder->success($this->service->create($table, $record, $params)); + } + } + + public function update(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $ids = explode(',', $id); + if (is_array($record)) { + if (count($ids) != count($record)) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->update($table, $ids[$i], $record[$i], $params); + } + return $this->responder->success($result); + } else { + if (count($ids) != 1) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + return $this->responder->success($this->service->update($table, $id, $record, $params)); + } + } + + public function delete(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $params = RequestUtils::getParams($request); + $ids = explode(',', $id); + if (count($ids) > 1) { + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->delete($table, $ids[$i], $params); + } + return $this->responder->success($result); + } else { + return $this->responder->success($this->service->delete($table, $id, $params)); + } + } + + public function increment(ServerRequestInterface $request): ResponseInterface + { + $table = RequestUtils::getPathSegment($request, 2); + if (!$this->service->hasTable($table)) { + return $this->responder->error(ErrorCode::TABLE_NOT_FOUND, $table); + } + if ($this->service->getType($table) != 'table') { + return $this->responder->error(ErrorCode::OPERATION_NOT_SUPPORTED, __FUNCTION__); + } + $id = RequestUtils::getPathSegment($request, 3); + $record = $request->getParsedBody(); + if ($record === null) { + return $this->responder->error(ErrorCode::HTTP_MESSAGE_NOT_READABLE, ''); + } + $params = RequestUtils::getParams($request); + $ids = explode(',', $id); + if (is_array($record)) { + if (count($ids) != count($record)) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + $result = array(); + for ($i = 0; $i < count($ids); $i++) { + $result[] = $this->service->increment($table, $ids[$i], $record[$i], $params); + } + return $this->responder->success($result); + } else { + if (count($ids) != 1) { + return $this->responder->error(ErrorCode::ARGUMENT_COUNT_MISMATCH, $id); + } + return $this->responder->success($this->service->increment($table, $id, $record, $params)); + } + } +} diff --git a/src/Tqdev/PhpCrudApi/Controller/Responder.php b/src/Tqdev/PhpCrudApi/Controller/Responder.php new file mode 100644 index 0000000..9f4d93e --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Controller/Responder.php @@ -0,0 +1,12 @@ +driver = $driver; + } + + public function convertColumnValue(ReflectedColumn $column): string + { + if ($column->isBoolean()) { + switch ($this->driver) { + case 'mysql': + return "IFNULL(IF(?,TRUE,FALSE),NULL)"; + case 'pgsql': + return "?"; + case 'sqlsrv': + return "?"; + } + } + if ($column->isBinary()) { + switch ($this->driver) { + case 'mysql': + return "FROM_BASE64(?)"; + case 'pgsql': + return "decode(?, 'base64')"; + case 'sqlsrv': + return "CONVERT(XML, ?).value('.','varbinary(max)')"; + } + } + if ($column->isGeometry()) { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ST_GeomFromText(?)"; + case 'sqlsrv': + return "geometry::STGeomFromText(?,0)"; + } + } + return '?'; + } + + public function convertColumnName(ReflectedColumn $column, $value): string + { + if ($column->isBinary()) { + switch ($this->driver) { + case 'mysql': + return "TO_BASE64($value) as $value"; + case 'pgsql': + return "encode($value::bytea, 'base64') as $value"; + case 'sqlsrv': + return "CASE WHEN $value IS NULL THEN NULL ELSE (SELECT CAST($value as varbinary(max)) FOR XML PATH(''), BINARY BASE64) END as $value"; + } + } + if ($column->isGeometry()) { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ST_AsText($value) as $value"; + case 'sqlsrv': + return "REPLACE($value.STAsText(),' (','(') as $value"; + } + } + return $value; + } +} diff --git a/src/Tqdev/PhpCrudApi/Database/ColumnsBuilder.php b/src/Tqdev/PhpCrudApi/Database/ColumnsBuilder.php new file mode 100644 index 0000000..9e436e8 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Database/ColumnsBuilder.php @@ -0,0 +1,119 @@ +driver = $driver; + $this->converter = new ColumnConverter($driver); + } + + public function getOffsetLimit(int $offset, int $limit): string + { + if ($limit < 0 || $offset < 0) { + return ''; + } + switch ($this->driver) { + case 'mysql': + return " LIMIT $offset, $limit"; + case 'pgsql': + return " LIMIT $limit OFFSET $offset"; + case 'sqlsrv': + return " OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY"; + case 'sqlite': + return " LIMIT $limit OFFSET $offset"; + } + } + + private function quoteColumnName(ReflectedColumn $column): string + { + return '"' . $column->getName() . '"'; + } + + public function getOrderBy(ReflectedTable $table, array $columnOrdering): string + { + if (count($columnOrdering) == 0) { + return ''; + } + $results = array(); + foreach ($columnOrdering as $i => list($columnName, $ordering)) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $results[] = $quotedColumnName . ' ' . $ordering; + } + return ' ORDER BY ' . implode(',', $results); + } + + public function getSelect(ReflectedTable $table, array $columnNames): string + { + $results = array(); + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $quotedColumnName = $this->converter->convertColumnName($column, $quotedColumnName); + $results[] = $quotedColumnName; + } + return implode(',', $results); + } + + public function getInsert(ReflectedTable $table, array $columnValues): string + { + $columns = array(); + $values = array(); + foreach ($columnValues as $columnName => $columnValue) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columns[] = $quotedColumnName; + $columnValue = $this->converter->convertColumnValue($column); + $values[] = $columnValue; + } + $columnsSql = '(' . implode(',', $columns) . ')'; + $valuesSql = '(' . implode(',', $values) . ')'; + $outputColumn = $this->quoteColumnName($table->getPk()); + switch ($this->driver) { + case 'mysql': + return "$columnsSql VALUES $valuesSql"; + case 'pgsql': + return "$columnsSql VALUES $valuesSql RETURNING $outputColumn"; + case 'sqlsrv': + return "$columnsSql OUTPUT INSERTED.$outputColumn VALUES $valuesSql"; + case 'sqlite': + return "$columnsSql VALUES $valuesSql"; + } + } + + public function getUpdate(ReflectedTable $table, array $columnValues): string + { + $results = array(); + foreach ($columnValues as $columnName => $columnValue) { + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columnValue = $this->converter->convertColumnValue($column); + $results[] = $quotedColumnName . '=' . $columnValue; + } + return implode(',', $results); + } + + public function getIncrement(ReflectedTable $table, array $columnValues): string + { + $results = array(); + foreach ($columnValues as $columnName => $columnValue) { + if (!is_numeric($columnValue)) { + continue; + } + $column = $table->getColumn($columnName); + $quotedColumnName = $this->quoteColumnName($column); + $columnValue = $this->converter->convertColumnValue($column); + $results[] = $quotedColumnName . '=' . $quotedColumnName . '+' . $columnValue; + } + return implode(',', $results); + } +} diff --git a/src/Tqdev/PhpCrudApi/Database/ConditionsBuilder.php b/src/Tqdev/PhpCrudApi/Database/ConditionsBuilder.php new file mode 100644 index 0000000..1e7385c --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Database/ConditionsBuilder.php @@ -0,0 +1,217 @@ +driver = $driver; + } + + private function getConditionSql(Condition $condition, array &$arguments): string + { + if ($condition instanceof AndCondition) { + return $this->getAndConditionSql($condition, $arguments); + } + if ($condition instanceof OrCondition) { + return $this->getOrConditionSql($condition, $arguments); + } + if ($condition instanceof NotCondition) { + return $this->getNotConditionSql($condition, $arguments); + } + if ($condition instanceof SpatialCondition) { + return $this->getSpatialConditionSql($condition, $arguments); + } + if ($condition instanceof ColumnCondition) { + return $this->getColumnConditionSql($condition, $arguments); + } + throw new \Exception('Unknown Condition: ' . get_class($condition)); + } + + private function getAndConditionSql(AndCondition $and, array &$arguments): string + { + $parts = []; + foreach ($and->getConditions() as $condition) { + $parts[] = $this->getConditionSql($condition, $arguments); + } + return '(' . implode(' AND ', $parts) . ')'; + } + + private function getOrConditionSql(OrCondition $or, array &$arguments): string + { + $parts = []; + foreach ($or->getConditions() as $condition) { + $parts[] = $this->getConditionSql($condition, $arguments); + } + return '(' . implode(' OR ', $parts) . ')'; + } + + private function getNotConditionSql(NotCondition $not, array &$arguments): string + { + $condition = $not->getCondition(); + return '(NOT ' . $this->getConditionSql($condition, $arguments) . ')'; + } + + private function quoteColumnName(ReflectedColumn $column): string + { + return '"' . $column->getName() . '"'; + } + + private function escapeLikeValue(string $value): string + { + return addcslashes($value, '%_'); + } + + private function getColumnConditionSql(ColumnCondition $condition, array &$arguments): string + { + $column = $this->quoteColumnName($condition->getColumn()); + $operator = $condition->getOperator(); + $value = $condition->getValue(); + switch ($operator) { + case 'cs': + $sql = "$column LIKE ?"; + $arguments[] = '%' . $this->escapeLikeValue($value) . '%'; + break; + case 'sw': + $sql = "$column LIKE ?"; + $arguments[] = $this->escapeLikeValue($value) . '%'; + break; + case 'ew': + $sql = "$column LIKE ?"; + $arguments[] = '%' . $this->escapeLikeValue($value); + break; + case 'eq': + $sql = "$column = ?"; + $arguments[] = $value; + break; + case 'lt': + $sql = "$column < ?"; + $arguments[] = $value; + break; + case 'le': + $sql = "$column <= ?"; + $arguments[] = $value; + break; + case 'ge': + $sql = "$column >= ?"; + $arguments[] = $value; + break; + case 'gt': + $sql = "$column > ?"; + $arguments[] = $value; + break; + case 'bt': + $parts = explode(',', $value, 2); + $count = count($parts); + if ($count == 2) { + $sql = "($column >= ? AND $column <= ?)"; + $arguments[] = $parts[0]; + $arguments[] = $parts[1]; + } else { + $sql = "FALSE"; + } + break; + case 'in': + $parts = explode(',', $value); + $count = count($parts); + if ($count > 0) { + $qmarks = implode(',', str_split(str_repeat('?', $count))); + $sql = "$column IN ($qmarks)"; + for ($i = 0; $i < $count; $i++) { + $arguments[] = $parts[$i]; + } + } else { + $sql = "FALSE"; + } + break; + case 'is': + $sql = "$column IS NULL"; + break; + } + return $sql; + } + + private function getSpatialFunctionName(string $operator): string + { + switch ($operator) { + case 'co': + return 'ST_Contains'; + case 'cr': + return 'ST_Crosses'; + case 'di': + return 'ST_Disjoint'; + case 'eq': + return 'ST_Equals'; + case 'in': + return 'ST_Intersects'; + case 'ov': + return 'ST_Overlaps'; + case 'to': + return 'ST_Touches'; + case 'wi': + return 'ST_Within'; + case 'ic': + return 'ST_IsClosed'; + case 'is': + return 'ST_IsSimple'; + case 'iv': + return 'ST_IsValid'; + } + } + + private function hasSpatialArgument(string $operator): bool + { + return in_array($operator, ['ic', 'is', 'iv']) ? false : true; + } + + private function getSpatialFunctionCall(string $functionName, string $column, bool $hasArgument): string + { + switch ($this->driver) { + case 'mysql': + case 'pgsql': + $argument = $hasArgument ? 'ST_GeomFromText(?)' : ''; + return "$functionName($column, $argument)=TRUE"; + case 'sqlsrv': + $functionName = str_replace('ST_', 'ST', $functionName); + $argument = $hasArgument ? 'geometry::STGeomFromText(?,0)' : ''; + return "$column.$functionName($argument)=1"; + case 'sqlite': + $argument = $hasArgument ? '?' : '0'; + return "$functionName($column, $argument)=1"; + } + } + + private function getSpatialConditionSql(ColumnCondition $condition, array &$arguments): string + { + $column = $this->quoteColumnName($condition->getColumn()); + $operator = $condition->getOperator(); + $value = $condition->getValue(); + $functionName = $this->getSpatialFunctionName($operator); + $hasArgument = $this->hasSpatialArgument($operator); + $sql = $this->getSpatialFunctionCall($functionName, $column, $hasArgument); + if ($hasArgument) { + $arguments[] = $value; + } + return $sql; + } + + public function getWhereClause(Condition $condition, array &$arguments): string + { + if ($condition instanceof NoCondition) { + return ''; + } + return ' WHERE ' . $this->getConditionSql($condition, $arguments); + } +} diff --git a/src/Tqdev/PhpCrudApi/Database/DataConverter.php b/src/Tqdev/PhpCrudApi/Database/DataConverter.php new file mode 100644 index 0000000..0c7dd27 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Database/DataConverter.php @@ -0,0 +1,104 @@ +driver = $driver; + } + + private function convertRecordValue($conversion, $value) + { + $args = explode('|', $conversion); + $type = array_shift($args); + switch ($type) { + case 'boolean': + return $value ? true : false; + case 'integer': + return (int) $value; + case 'float': + return (float) $value; + case 'decimal': + return number_format($value, $args[0], '.', ''); + } + return $value; + } + + private function getRecordValueConversion(ReflectedColumn $column): string + { + if (in_array($this->driver, ['mysql', 'sqlsrv', 'sqlite']) && $column->isBoolean()) { + return 'boolean'; + } + if (in_array($this->driver, ['sqlsrv', 'sqlite']) && in_array($column->getType(), ['integer', 'bigint'])) { + return 'integer'; + } + if (in_array($this->driver, ['sqlite', 'pgsql']) && in_array($column->getType(), ['float', 'double'])) { + return 'float'; + } + if (in_array($this->driver, ['sqlite']) && in_array($column->getType(), ['decimal'])) { + return 'decimal|' . $column->getScale(); + } + return 'none'; + } + + public function convertRecords(ReflectedTable $table, array $columnNames, array &$records) /*: void*/ + { + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $conversion = $this->getRecordValueConversion($column); + if ($conversion != 'none') { + foreach ($records as $i => $record) { + $value = $records[$i][$columnName]; + if ($value === null) { + continue; + } + $records[$i][$columnName] = $this->convertRecordValue($conversion, $value); + } + } + } + } + + private function convertInputValue($conversion, $value) + { + switch ($conversion) { + case 'boolean': + return $value ? 1 : 0; + case 'base64url_to_base64': + return str_pad(strtr($value, '-_', '+/'), ceil(strlen($value) / 4) * 4, '=', STR_PAD_RIGHT); + } + return $value; + } + + private function getInputValueConversion(ReflectedColumn $column): string + { + if ($column->isBoolean()) { + return 'boolean'; + } + if ($column->isBinary()) { + return 'base64url_to_base64'; + } + return 'none'; + } + + public function convertColumnValues(ReflectedTable $table, array &$columnValues) /*: void*/ + { + $columnNames = array_keys($columnValues); + foreach ($columnNames as $columnName) { + $column = $table->getColumn($columnName); + $conversion = $this->getInputValueConversion($column); + if ($conversion != 'none') { + $value = $columnValues[$columnName]; + if ($value !== null) { + $columnValues[$columnName] = $this->convertInputValue($conversion, $value); + } + } + } + } +} diff --git a/src/Tqdev/PhpCrudApi/Database/GenericDB.php b/src/Tqdev/PhpCrudApi/Database/GenericDB.php new file mode 100644 index 0000000..23369ac --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Database/GenericDB.php @@ -0,0 +1,340 @@ +driver) { + case 'mysql': + return "$this->driver:host=$this->address;port=$this->port;dbname=$this->database;charset=utf8mb4"; + case 'pgsql': + return "$this->driver:host=$this->address port=$this->port dbname=$this->database options='--client_encoding=UTF8'"; + case 'sqlsrv': + return "$this->driver:Server=$this->address,$this->port;Database=$this->database"; + case 'sqlite': + return "$this->driver:$this->address"; + } + } + + private function getCommands(): array + { + switch ($this->driver) { + case 'mysql': + return [ + 'SET SESSION sql_warnings=1;', + 'SET NAMES utf8mb4;', + 'SET SESSION sql_mode = "ANSI,TRADITIONAL";', + ]; + case 'pgsql': + return [ + "SET NAMES 'UTF8';", + ]; + case 'sqlsrv': + return []; + case 'sqlite': + return [ + 'PRAGMA foreign_keys = on;', + ]; + } + } + + private function getOptions(): array + { + $options = array( + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ); + switch ($this->driver) { + case 'mysql': + return $options + [ + \PDO::ATTR_EMULATE_PREPARES => false, + \PDO::MYSQL_ATTR_FOUND_ROWS => true, + \PDO::ATTR_PERSISTENT => true, + ]; + case 'pgsql': + return $options + [ + \PDO::ATTR_EMULATE_PREPARES => false, + \PDO::ATTR_PERSISTENT => true, + ]; + case 'sqlsrv': + return $options + [ + \PDO::SQLSRV_ATTR_DIRECT_QUERY => false, + \PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => true, + ]; + case 'sqlite': + return $options + []; + } + } + + private function initPdo(): bool + { + if ($this->pdo) { + $result = $this->pdo->reconstruct($this->getDsn(), $this->username, $this->password, $this->getOptions()); + } else { + $this->pdo = new LazyPdo($this->getDsn(), $this->username, $this->password, $this->getOptions()); + $result = true; + } + $commands = $this->getCommands(); + foreach ($commands as $command) { + $this->pdo->addInitCommand($command); + } + $this->reflection = new GenericReflection($this->pdo, $this->driver, $this->database, $this->tables); + $this->definition = new GenericDefinition($this->pdo, $this->driver, $this->database, $this->tables); + $this->conditions = new ConditionsBuilder($this->driver); + $this->columns = new ColumnsBuilder($this->driver); + $this->converter = new DataConverter($this->driver); + return $result; + } + + public function __construct(string $driver, string $address, int $port, string $database, array $tables, string $username, string $password) + { + $this->driver = $driver; + $this->address = $address; + $this->port = $port; + $this->database = $database; + $this->tables = $tables; + $this->username = $username; + $this->password = $password; + $this->initPdo(); + } + + public function reconstruct(string $driver, string $address, int $port, string $database, array $tables, string $username, string $password): bool + { + if ($driver) { + $this->driver = $driver; + } + if ($address) { + $this->address = $address; + } + if ($port) { + $this->port = $port; + } + if ($database) { + $this->database = $database; + } + if ($tables) { + $this->tables = $tables; + } + if ($username) { + $this->username = $username; + } + if ($password) { + $this->password = $password; + } + return $this->initPdo(); + } + + public function pdo(): LazyPdo + { + return $this->pdo; + } + + public function reflection(): GenericReflection + { + return $this->reflection; + } + + public function definition(): GenericDefinition + { + return $this->definition; + } + + private function addMiddlewareConditions(string $tableName, Condition $condition): Condition + { + $condition1 = VariableStore::get("authorization.conditions.$tableName"); + if ($condition1) { + $condition = $condition->_and($condition1); + } + $condition2 = VariableStore::get("multiTenancy.conditions.$tableName"); + if ($condition2) { + $condition = $condition->_and($condition2); + } + return $condition; + } + + public function createSingle(ReflectedTable $table, array $columnValues) /*: ?String*/ + { + $this->converter->convertColumnValues($table, $columnValues); + $insertColumns = $this->columns->getInsert($table, $columnValues); + $tableName = $table->getName(); + $pkName = $table->getPk()->getName(); + $parameters = array_values($columnValues); + $sql = 'INSERT INTO "' . $tableName . '" ' . $insertColumns; + $stmt = $this->query($sql, $parameters); + // return primary key value if specified in the input + if (isset($columnValues[$pkName])) { + return $columnValues[$pkName]; + } + // work around missing "returning" or "output" in mysql + switch ($this->driver) { + case 'mysql': + $stmt = $this->query('SELECT LAST_INSERT_ID()', []); + break; + case 'sqlite': + $stmt = $this->query('SELECT LAST_INSERT_ROWID()', []); + break; + } + $pkValue = $stmt->fetchColumn(0); + if ($this->driver == 'sqlsrv' && $table->getPk()->getType() == 'bigint') { + return (int) $pkValue; + } + if ($this->driver == 'sqlite' && in_array($table->getPk()->getType(), ['integer', 'bigint'])) { + return (int) $pkValue; + } + return $pkValue; + } + + public function selectSingle(ReflectedTable $table, array $columnNames, string $id) /*: ?array*/ + { + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + $record = $stmt->fetch() ?: null; + if ($record === null) { + return null; + } + $records = array($record); + $this->converter->convertRecords($table, $columnNames, $records); + return $records[0]; + } + + public function selectMultiple(ReflectedTable $table, array $columnNames, array $ids): array + { + if (count($ids) == 0) { + return []; + } + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'in', implode(',', $ids)); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + $records = $stmt->fetchAll(); + $this->converter->convertRecords($table, $columnNames, $records); + return $records; + } + + public function selectCount(ReflectedTable $table, Condition $condition): int + { + $tableName = $table->getName(); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'SELECT COUNT(*) FROM "' . $tableName . '"' . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->fetchColumn(0); + } + + public function selectAll(ReflectedTable $table, array $columnNames, Condition $condition, array $columnOrdering, int $offset, int $limit): array + { + if ($limit == 0) { + return array(); + } + $selectColumns = $this->columns->getSelect($table, $columnNames); + $tableName = $table->getName(); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $orderBy = $this->columns->getOrderBy($table, $columnOrdering); + $offsetLimit = $this->columns->getOffsetLimit($offset, $limit); + $sql = 'SELECT ' . $selectColumns . ' FROM "' . $tableName . '"' . $whereClause . $orderBy . $offsetLimit; + $stmt = $this->query($sql, $parameters); + $records = $stmt->fetchAll(); + $this->converter->convertRecords($table, $columnNames, $records); + return $records; + } + + public function updateSingle(ReflectedTable $table, array $columnValues, string $id) + { + if (count($columnValues) == 0) { + return 0; + } + $this->converter->convertColumnValues($table, $columnValues); + $updateColumns = $this->columns->getUpdate($table, $columnValues); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array_values($columnValues); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'UPDATE "' . $tableName . '" SET ' . $updateColumns . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + public function deleteSingle(ReflectedTable $table, string $id) + { + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array(); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'DELETE FROM "' . $tableName . '" ' . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + public function incrementSingle(ReflectedTable $table, array $columnValues, string $id) + { + if (count($columnValues) == 0) { + return 0; + } + $this->converter->convertColumnValues($table, $columnValues); + $updateColumns = $this->columns->getIncrement($table, $columnValues); + $tableName = $table->getName(); + $condition = new ColumnCondition($table->getPk(), 'eq', $id); + $condition = $this->addMiddlewareConditions($tableName, $condition); + $parameters = array_values($columnValues); + $whereClause = $this->conditions->getWhereClause($condition, $parameters); + $sql = 'UPDATE "' . $tableName . '" SET ' . $updateColumns . $whereClause; + $stmt = $this->query($sql, $parameters); + return $stmt->rowCount(); + } + + private function query(string $sql, array $parameters): \PDOStatement + { + $stmt = $this->pdo->prepare($sql); + //echo "- $sql -- " . json_encode($parameters, JSON_UNESCAPED_UNICODE) . "\n"; + $stmt->execute($parameters); + return $stmt; + } + + public function getCacheKey(): string + { + return md5(json_encode([ + $this->driver, + $this->address, + $this->port, + $this->database, + $this->tables, + $this->username + ])); + } +} diff --git a/src/Tqdev/PhpCrudApi/Database/GenericDefinition.php b/src/Tqdev/PhpCrudApi/Database/GenericDefinition.php new file mode 100644 index 0000000..b664fa3 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Database/GenericDefinition.php @@ -0,0 +1,446 @@ +pdo = $pdo; + $this->driver = $driver; + $this->database = $database; + $this->typeConverter = new TypeConverter($driver); + $this->reflection = new GenericReflection($pdo, $driver, $database, $tables); + } + + private function quote(string $identifier): string + { + return '"' . str_replace('"', '', $identifier) . '"'; + } + + public function getColumnType(ReflectedColumn $column, bool $update): string + { + if ($this->driver == 'pgsql' && !$update && $column->getPk() && $this->canAutoIncrement($column)) { + return 'serial'; + } + $type = $this->typeConverter->fromJdbc($column->getType()); + if ($column->hasPrecision() && $column->hasScale()) { + $size = '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; + } elseif ($column->hasPrecision()) { + $size = '(' . $column->getPrecision() . ')'; + } elseif ($column->hasLength()) { + $size = '(' . $column->getLength() . ')'; + } else { + $size = ''; + } + $null = $this->getColumnNullType($column, $update); + $auto = $this->getColumnAutoIncrement($column, $update); + return $type . $size . $null . $auto; + } + + private function getPrimaryKey(string $tableName): string + { + $pks = $this->reflection->getTablePrimaryKeys($tableName); + if (count($pks) == 1) { + return $pks[0]; + } + return ""; + } + + private function canAutoIncrement(ReflectedColumn $column): bool + { + return in_array($column->getType(), ['integer', 'bigint']); + } + + private function getColumnAutoIncrement(ReflectedColumn $column, bool $update): string + { + if (!$this->canAutoIncrement($column)) { + return ''; + } + switch ($this->driver) { + case 'mysql': + return $column->getPk() ? ' AUTO_INCREMENT' : ''; + case 'pgsql': + case 'sqlsrv': + return $column->getPk() ? ' IDENTITY(1,1)' : ''; + case 'sqlite': + return $column->getPk() ? ' AUTOINCREMENT' : ''; + } + } + + private function getColumnNullType(ReflectedColumn $column, bool $update): string + { + if ($this->driver == 'pgsql' && $update) { + return ''; + } + return $column->getNullable() ? ' NULL' : ' NOT NULL'; + } + + private function getTableRenameSQL(string $tableName, string $newTableName): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($newTableName); + + switch ($this->driver) { + case 'mysql': + return "RENAME TABLE $p1 TO $p2"; + case 'pgsql': + return "ALTER TABLE $p1 RENAME TO $p2"; + case 'sqlsrv': + return "EXEC sp_rename $p1, $p2"; + case 'sqlite': + return "ALTER TABLE $p1 RENAME TO $p2"; + } + } + + private function getColumnRenameSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + + switch ($this->driver) { + case 'mysql': + $p4 = $this->getColumnType($newColumn, true); + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + return "ALTER TABLE $p1 RENAME COLUMN $p2 TO $p3"; + case 'sqlsrv': + $p4 = $this->quote($tableName . '.' . $columnName); + return "EXEC sp_rename $p4, $p3, 'COLUMN'"; + case 'sqlite': + return "ALTER TABLE $p1 RENAME COLUMN $p2 TO $p3"; + } + } + + private function getColumnRetypeSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + return "ALTER TABLE $p1 ALTER COLUMN $p3 TYPE $p4"; + case 'sqlsrv': + return "ALTER TABLE $p1 ALTER COLUMN $p3 $p4"; + } + } + + private function getSetColumnNullableSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + $p5 = $newColumn->getNullable() ? 'DROP NOT NULL' : 'SET NOT NULL'; + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p5"; + case 'sqlsrv': + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p4"; + } + } + + private function getSetColumnPkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_pkey'); + + switch ($this->driver) { + case 'mysql': + $p4 = $newColumn->getPk() ? "ADD PRIMARY KEY ($p2)" : 'DROP PRIMARY KEY'; + return "ALTER TABLE $p1 $p4"; + case 'pgsql': + case 'sqlsrv': + $p4 = $newColumn->getPk() ? "ADD CONSTRAINT $p3 PRIMARY KEY ($p2)" : "DROP CONSTRAINT $p3"; + return "ALTER TABLE $p1 $p4"; + } + } + + private function getSetColumnPkSequenceSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + + switch ($this->driver) { + case 'mysql': + return "select 1"; + case 'pgsql': + return $newColumn->getPk() ? "CREATE SEQUENCE $p3 OWNED BY $p1.$p2" : "DROP SEQUENCE $p3"; + case 'sqlsrv': + return $newColumn->getPk() ? "CREATE SEQUENCE $p3" : "DROP SEQUENCE $p3"; + } + } + + private function getSetColumnPkSequenceStartSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + return "select 1"; + case 'pgsql': + $p3 = $this->pdo->quote($tableName . '_' . $columnName . '_seq'); + return "SELECT setval($p3, (SELECT max($p2)+1 FROM $p1));"; + case 'sqlsrv': + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + $p4 = $this->pdo->query("SELECT max($p2)+1 FROM $p1")->fetchColumn(); + return "ALTER SEQUENCE $p3 RESTART WITH $p4"; + } + } + + private function getSetColumnPkDefaultSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + $p3 = $this->quote($newColumn->getName()); + $p4 = $this->getColumnType($newColumn, true); + return "ALTER TABLE $p1 CHANGE $p2 $p3 $p4"; + case 'pgsql': + if ($newColumn->getPk()) { + $p3 = $this->pdo->quote($tableName . '_' . $columnName . '_seq'); + $p4 = "SET DEFAULT nextval($p3)"; + } else { + $p4 = 'DROP DEFAULT'; + } + return "ALTER TABLE $p1 ALTER COLUMN $p2 $p4"; + case 'sqlsrv': + $p3 = $this->quote($tableName . '_' . $columnName . '_seq'); + $p4 = $this->quote($tableName . '_' . $columnName . '_def'); + if ($newColumn->getPk()) { + return "ALTER TABLE $p1 ADD CONSTRAINT $p4 DEFAULT NEXT VALUE FOR $p3 FOR $p2"; + } else { + return "ALTER TABLE $p1 DROP CONSTRAINT $p4"; + } + } + } + + private function getAddColumnFkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + $p3 = $this->quote($tableName . '_' . $columnName . '_fkey'); + $p4 = $this->quote($newColumn->getFk()); + $p5 = $this->quote($this->getPrimaryKey($newColumn->getFk())); + + return "ALTER TABLE $p1 ADD CONSTRAINT $p3 FOREIGN KEY ($p2) REFERENCES $p4 ($p5)"; + } + + private function getRemoveColumnFkConstraintSQL(string $tableName, string $columnName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($tableName . '_' . $columnName . '_fkey'); + + switch ($this->driver) { + case 'mysql': + return "ALTER TABLE $p1 DROP FOREIGN KEY $p2"; + case 'pgsql': + case 'sqlsrv': + return "ALTER TABLE $p1 DROP CONSTRAINT $p2"; + } + } + + private function getAddTableSQL(ReflectedTable $newTable): string + { + $tableName = $newTable->getName(); + $p1 = $this->quote($tableName); + $fields = []; + $constraints = []; + foreach ($newTable->getColumnNames() as $columnName) { + $pkColumn = $this->getPrimaryKey($tableName); + $newColumn = $newTable->getColumn($columnName); + $f1 = $this->quote($columnName); + $f2 = $this->getColumnType($newColumn, false); + $f3 = $this->quote($tableName . '_' . $columnName . '_fkey'); + $f4 = $this->quote($newColumn->getFk()); + $f5 = $this->quote($this->getPrimaryKey($newColumn->getFk())); + $f6 = $this->quote($tableName . '_' . $pkColumn . '_pkey'); + if ($this->driver == 'sqlite') { + if ($newColumn->getPk()) { + $f2 = str_replace('NULL', 'NULL PRIMARY KEY', $f2); + } + $fields[] = "$f1 $f2"; + if ($newColumn->getFk()) { + $constraints[] = "FOREIGN KEY ($f1) REFERENCES $f4 ($f5)"; + } + } else { + $fields[] = "$f1 $f2"; + if ($newColumn->getPk()) { + $constraints[] = "CONSTRAINT $f6 PRIMARY KEY ($f1)"; + } + if ($newColumn->getFk()) { + $constraints[] = "CONSTRAINT $f3 FOREIGN KEY ($f1) REFERENCES $f4 ($f5)"; + } + } + } + $p2 = implode(',', array_merge($fields, $constraints)); + + return "CREATE TABLE $p1 ($p2);"; + } + + private function getAddColumnSQL(string $tableName, ReflectedColumn $newColumn): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($newColumn->getName()); + $p3 = $this->getColumnType($newColumn, false); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ALTER TABLE $p1 ADD COLUMN $p2 $p3"; + case 'sqlsrv': + return "ALTER TABLE $p1 ADD $p2 $p3"; + case 'sqlite': + return "ALTER TABLE $p1 ADD COLUMN $p2 $p3"; + } + } + + private function getRemoveTableSQL(string $tableName): string + { + $p1 = $this->quote($tableName); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "DROP TABLE $p1 CASCADE;"; + case 'sqlsrv': + return "DROP TABLE $p1;"; + case 'sqlite': + return "DROP TABLE $p1;"; + } + } + + private function getRemoveColumnSQL(string $tableName, string $columnName): string + { + $p1 = $this->quote($tableName); + $p2 = $this->quote($columnName); + + switch ($this->driver) { + case 'mysql': + case 'pgsql': + return "ALTER TABLE $p1 DROP COLUMN $p2 CASCADE;"; + case 'sqlsrv': + return "ALTER TABLE $p1 DROP COLUMN $p2;"; + case 'sqlite': + return "ALTER TABLE $p1 DROP COLUMN $p2;"; + } + } + + public function renameTable(string $tableName, string $newTableName) + { + $sql = $this->getTableRenameSQL($tableName, $newTableName); + return $this->query($sql, []); + } + + public function renameColumn(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getColumnRenameSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function retypeColumn(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getColumnRetypeSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function setColumnNullable(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getSetColumnNullableSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function addColumnPrimaryKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getSetColumnPkConstraintSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + if ($this->canAutoIncrement($newColumn)) { + $sql = $this->getSetColumnPkSequenceSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkSequenceStartSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkDefaultSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + } + return true; + } + + public function removeColumnPrimaryKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + if ($this->canAutoIncrement($newColumn)) { + $sql = $this->getSetColumnPkDefaultSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + $sql = $this->getSetColumnPkSequenceSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + } + $sql = $this->getSetColumnPkConstraintSQL($tableName, $columnName, $newColumn); + $this->query($sql, []); + return true; + } + + public function addColumnForeignKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getAddColumnFkConstraintSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function removeColumnForeignKey(string $tableName, string $columnName, ReflectedColumn $newColumn) + { + $sql = $this->getRemoveColumnFkConstraintSQL($tableName, $columnName, $newColumn); + return $this->query($sql, []); + } + + public function addTable(ReflectedTable $newTable) + { + $sql = $this->getAddTableSQL($newTable); + return $this->query($sql, []); + } + + public function addColumn(string $tableName, ReflectedColumn $newColumn) + { + $sql = $this->getAddColumnSQL($tableName, $newColumn); + return $this->query($sql, []); + } + + public function removeTable(string $tableName) + { + $sql = $this->getRemoveTableSQL($tableName); + return $this->query($sql, []); + } + + public function removeColumn(string $tableName, string $columnName) + { + $sql = $this->getRemoveColumnSQL($tableName, $columnName); + return $this->query($sql, []); + } + + private function query(string $sql, array $arguments): bool + { + $stmt = $this->pdo->prepare($sql); + // echo "- $sql -- " . json_encode($arguments) . "\n"; + return $stmt->execute($arguments); + } +} diff --git a/src/Tqdev/PhpCrudApi/Database/GenericReflection.php b/src/Tqdev/PhpCrudApi/Database/GenericReflection.php new file mode 100644 index 0000000..a839a17 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Database/GenericReflection.php @@ -0,0 +1,206 @@ +pdo = $pdo; + $this->driver = $driver; + $this->database = $database; + $this->tables = $tables; + $this->typeConverter = new TypeConverter($driver); + } + + public function getIgnoredTables(): array + { + switch ($this->driver) { + case 'mysql': + return []; + case 'pgsql': + return ['spatial_ref_sys', 'raster_columns', 'raster_overviews', 'geography_columns', 'geometry_columns']; + case 'sqlsrv': + return []; + case 'sqlite': + return []; + } + } + + private function getTablesSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "TABLE_NAME", "TABLE_TYPE" FROM "INFORMATION_SCHEMA"."TABLES" WHERE "TABLE_TYPE" IN (\'BASE TABLE\' , \'VIEW\') AND "TABLE_SCHEMA" = ? ORDER BY BINARY "TABLE_NAME"'; + case 'pgsql': + return 'SELECT c.relname as "TABLE_NAME", c.relkind as "TABLE_TYPE" FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN (\'r\', \'v\') AND n.nspname <> \'pg_catalog\' AND n.nspname <> \'information_schema\' AND n.nspname !~ \'^pg_toast\' AND pg_catalog.pg_table_is_visible(c.oid) AND \'\' <> ? ORDER BY "TABLE_NAME";'; + case 'sqlsrv': + return 'SELECT o.name as "TABLE_NAME", o.xtype as "TABLE_TYPE" FROM sysobjects o WHERE o.xtype IN (\'U\', \'V\') ORDER BY "TABLE_NAME"'; + case 'sqlite': + return 'SELECT t.name as "TABLE_NAME", t.type as "TABLE_TYPE" FROM sqlite_master t WHERE t.type IN (\'table\', \'view\') AND \'\' <> ? ORDER BY "TABLE_NAME"'; + } + } + + private function getTableColumnsSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME", "IS_NULLABLE", "DATA_TYPE", "CHARACTER_MAXIMUM_LENGTH" as "CHARACTER_MAXIMUM_LENGTH", "NUMERIC_PRECISION", "NUMERIC_SCALE", "COLUMN_TYPE" FROM "INFORMATION_SCHEMA"."COLUMNS" WHERE "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ? ORDER BY "ORDINAL_POSITION"'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME", case when a.attnotnull then \'NO\' else \'YES\' end as "IS_NULLABLE", pg_catalog.format_type(a.atttypid, -1) as "DATA_TYPE", case when a.atttypmod < 0 then NULL else a.atttypmod-4 end as "CHARACTER_MAXIMUM_LENGTH", case when a.atttypid != 1700 then NULL else ((a.atttypmod - 4) >> 16) & 65535 end as "NUMERIC_PRECISION", case when a.atttypid != 1700 then NULL else (a.atttypmod - 4) & 65535 end as "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM pg_attribute a JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum;'; + case 'sqlsrv': + return 'SELECT c.name AS "COLUMN_NAME", c.is_nullable AS "IS_NULLABLE", t.Name AS "DATA_TYPE", (c.max_length/2) AS "CHARACTER_MAXIMUM_LENGTH", c.precision AS "NUMERIC_PRECISION", c.scale AS "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM sys.columns c INNER JOIN sys.types t ON c.user_type_id = t.user_type_id WHERE c.object_id = OBJECT_ID(?) AND \'\' <> ? ORDER BY c.column_id'; + case 'sqlite': + return 'SELECT "name" AS "COLUMN_NAME", case when "notnull"==1 then \'no\' else \'yes\' end as "IS_NULLABLE", lower("type") AS "DATA_TYPE", 2147483647 AS "CHARACTER_MAXIMUM_LENGTH", 0 AS "NUMERIC_PRECISION", 0 AS "NUMERIC_SCALE", \'\' AS "COLUMN_TYPE" FROM pragma_table_info(?) WHERE \'\' <> ? ORDER BY "cid"'; + } + } + + private function getTablePrimaryKeysSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME" FROM "INFORMATION_SCHEMA"."KEY_COLUMN_USAGE" WHERE "CONSTRAINT_NAME" = \'PRIMARY\' AND "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ?'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME" FROM pg_attribute a JOIN pg_constraint c ON (c.conrelid, c.conkey[1]) = (a.attrelid, a.attnum) JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND c.contype = \'p\''; + case 'sqlsrv': + return 'SELECT c.NAME as "COLUMN_NAME" FROM sys.key_constraints kc inner join sys.objects t on t.object_id = kc.parent_object_id INNER JOIN sys.index_columns ic ON kc.parent_object_id = ic.object_id and kc.unique_index_id = ic.index_id INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id WHERE kc.type = \'PK\' and t.object_id = OBJECT_ID(?) and \'\' <> ?'; + case 'sqlite': + return 'SELECT "name" as "COLUMN_NAME" FROM pragma_table_info(?) WHERE "pk"=1 AND \'\' <> ?'; + } + } + + private function getTableForeignKeysSQL(): string + { + switch ($this->driver) { + case 'mysql': + return 'SELECT "COLUMN_NAME", "REFERENCED_TABLE_NAME" FROM "INFORMATION_SCHEMA"."KEY_COLUMN_USAGE" WHERE "REFERENCED_TABLE_NAME" IS NOT NULL AND "TABLE_NAME" = ? AND "TABLE_SCHEMA" = ?'; + case 'pgsql': + return 'SELECT a.attname AS "COLUMN_NAME", c.confrelid::regclass::text AS "REFERENCED_TABLE_NAME" FROM pg_attribute a JOIN pg_constraint c ON (c.conrelid, c.conkey[1]) = (a.attrelid, a.attnum) JOIN pg_class pgc ON pgc.oid = a.attrelid WHERE pgc.relname = ? AND \'\' <> ? AND c.contype = \'f\''; + case 'sqlsrv': + return 'SELECT COL_NAME(fc.parent_object_id, fc.parent_column_id) AS "COLUMN_NAME", OBJECT_NAME (f.referenced_object_id) AS "REFERENCED_TABLE_NAME" FROM sys.foreign_keys AS f INNER JOIN sys.foreign_key_columns AS fc ON f.OBJECT_ID = fc.constraint_object_id WHERE f.parent_object_id = OBJECT_ID(?) and \'\' <> ?'; + case 'sqlite': + return 'SELECT "from" AS "COLUMN_NAME", "table" AS "REFERENCED_TABLE_NAME" FROM pragma_foreign_key_list(?) WHERE \'\' <> ?'; + } + } + + public function getDatabaseName(): string + { + return $this->database; + } + + public function getTables(): array + { + $sql = $this->getTablesSQL(); + $results = $this->query($sql, [$this->database]); + $tables = $this->tables; + $results = array_filter($results, function ($v) use ($tables) { + return !$tables || in_array($v['TABLE_NAME'], $tables); + }); + foreach ($results as &$result) { + $map = []; + switch ($this->driver) { + case 'mysql': + $map = ['BASE TABLE' => 'table', 'VIEW' => 'view']; + break; + case 'pgsql': + $map = ['r' => 'table', 'v' => 'view']; + break; + case 'sqlsrv': + $map = ['U' => 'table', 'V' => 'view']; + break; + case 'sqlite': + $map = ['table' => 'table', 'view' => 'view']; + break; + } + $result['TABLE_TYPE'] = $map[trim($result['TABLE_TYPE'])]; + } + return $results; + } + + public function getTableColumns(string $tableName, string $type): array + { + $sql = $this->getTableColumnsSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + if ($type == 'view') { + foreach ($results as &$result) { + $result['IS_NULLABLE'] = false; + } + } + if ($this->driver == 'mysql') { + foreach ($results as &$result) { + // mysql does not properly reflect display width of types + preg_match('|([a-z]+)(\(([0-9]+)(,([0-9]+))?\))?|', $result['DATA_TYPE'], $matches); + $result['DATA_TYPE'] = $matches[1]; + if (!$result['CHARACTER_MAXIMUM_LENGTH']) { + if (isset($matches[3])) { + $result['NUMERIC_PRECISION'] = $matches[3]; + } + if (isset($matches[5])) { + $result['NUMERIC_SCALE'] = $matches[5]; + } + } + } + } + if ($this->driver == 'sqlite') { + foreach ($results as &$result) { + // sqlite does not properly reflect display width of types + preg_match('|([a-z]+)(\(([0-9]+)(,([0-9]+))?\))?|', $result['DATA_TYPE'], $matches); + if (isset($matches[1])) { + $result['DATA_TYPE'] = $matches[1]; + } else { + $result['DATA_TYPE'] = 'integer'; + } + if (isset($matches[5])) { + $result['NUMERIC_PRECISION'] = $matches[3]; + $result['NUMERIC_SCALE'] = $matches[5]; + } else if (isset($matches[3])) { + $result['CHARACTER_MAXIMUM_LENGTH'] = $matches[3]; + } + } + } + return $results; + } + + public function getTablePrimaryKeys(string $tableName): array + { + $sql = $this->getTablePrimaryKeysSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + $primaryKeys = []; + foreach ($results as $result) { + $primaryKeys[] = $result['COLUMN_NAME']; + } + return $primaryKeys; + } + + public function getTableForeignKeys(string $tableName): array + { + $sql = $this->getTableForeignKeysSQL(); + $results = $this->query($sql, [$tableName, $this->database]); + $foreignKeys = []; + foreach ($results as $result) { + $foreignKeys[$result['COLUMN_NAME']] = $result['REFERENCED_TABLE_NAME']; + } + return $foreignKeys; + } + + public function toJdbcType(string $type, string $size): string + { + return $this->typeConverter->toJdbc($type, $size); + } + + private function query(string $sql, array $parameters): array + { + $stmt = $this->pdo->prepare($sql); + //echo "- $sql -- " . json_encode($parameters, JSON_UNESCAPED_UNICODE) . "\n"; + $stmt->execute($parameters); + return $stmt->fetchAll(); + } +} diff --git a/src/Tqdev/PhpCrudApi/Database/LazyPdo.php b/src/Tqdev/PhpCrudApi/Database/LazyPdo.php new file mode 100644 index 0000000..87c2797 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Database/LazyPdo.php @@ -0,0 +1,124 @@ +dsn = $dsn; + $this->user = $user; + $this->password = $password; + $this->options = $options; + $this->commands = array(); + // explicitly NOT calling super::__construct + } + + public function addInitCommand(string $command)/*: void*/ + { + $this->commands[] = $command; + } + + private function pdo() + { + if (!$this->pdo) { + $this->pdo = new \PDO($this->dsn, $this->user, $this->password, $this->options); + foreach ($this->commands as $command) { + $this->pdo->query($command); + } + } + return $this->pdo; + } + + public function reconstruct(string $dsn, /*?string*/ $user = null, /*?string*/ $password = null, array $options = array()): bool + { + $this->dsn = $dsn; + $this->user = $user; + $this->password = $password; + $this->options = $options; + $this->commands = array(); + if ($this->pdo) { + $this->pdo = null; + return true; + } + return false; + } + + public function inTransaction(): bool + { + // Do not call parent method if there is no pdo object + return $this->pdo && parent::inTransaction(); + } + + public function setAttribute($attribute, $value): bool + { + if ($this->pdo) { + return $this->pdo()->setAttribute($attribute, $value); + } + $this->options[$attribute] = $value; + return true; + } + + public function getAttribute($attribute): mixed + { + return $this->pdo()->getAttribute($attribute); + } + + public function beginTransaction(): bool + { + return $this->pdo()->beginTransaction(); + } + + public function commit(): bool + { + return $this->pdo()->commit(); + } + + public function rollBack(): bool + { + return $this->pdo()->rollBack(); + } + + public function errorCode(): mixed + { + return $this->pdo()->errorCode(); + } + + public function errorInfo(): array + { + return $this->pdo()->errorInfo(); + } + + public function exec($query): int + { + return $this->pdo()->exec($query); + } + + public function prepare($statement, $options = array()) + { + return $this->pdo()->prepare($statement, $options); + } + + public function quote($string, $parameter_type = null): string + { + return $this->pdo()->quote($string, $parameter_type); + } + + public function lastInsertId(/* ?string */$name = null): string + { + return $this->pdo()->lastInsertId($name); + } + + public function query(string $statement): \PDOStatement + { + return call_user_func_array(array($this->pdo(), 'query'), func_get_args()); + } +} diff --git a/src/Tqdev/PhpCrudApi/Database/TypeConverter.php b/src/Tqdev/PhpCrudApi/Database/TypeConverter.php new file mode 100644 index 0000000..e9070d9 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Database/TypeConverter.php @@ -0,0 +1,219 @@ +driver = $driver; + } + + private $fromJdbc = [ + 'mysql' => [ + 'clob' => 'longtext', + 'boolean' => 'tinyint(1)', + 'blob' => 'longblob', + 'timestamp' => 'datetime', + ], + 'pgsql' => [ + 'clob' => 'text', + 'blob' => 'bytea', + 'float' => 'real', + 'double' => 'double precision', + 'varbinary' => 'bytea', + ], + 'sqlsrv' => [ + 'boolean' => 'bit', + 'varchar' => 'nvarchar', + 'clob' => 'ntext', + 'blob' => 'image', + 'time' => 'time(0)', + 'timestamp' => 'datetime2(0)', + 'double' => 'float', + 'float' => 'real', + ], + ]; + + private $toJdbc = [ + 'simplified' => [ + 'char' => 'varchar', + 'longvarchar' => 'clob', + 'nchar' => 'varchar', + 'nvarchar' => 'varchar', + 'longnvarchar' => 'clob', + 'binary' => 'varbinary', + 'longvarbinary' => 'blob', + 'tinyint' => 'integer', + 'smallint' => 'integer', + 'real' => 'float', + 'numeric' => 'decimal', + 'nclob' => 'clob', + 'time_with_timezone' => 'time', + 'timestamp_with_timezone' => 'timestamp', + ], + 'mysql' => [ + 'tinyint(1)' => 'boolean', + 'bit(1)' => 'boolean', + 'tinyblob' => 'blob', + 'mediumblob' => 'blob', + 'longblob' => 'blob', + 'tinytext' => 'clob', + 'mediumtext' => 'clob', + 'longtext' => 'clob', + 'text' => 'clob', + 'mediumint' => 'integer', + 'int' => 'integer', + 'polygon' => 'geometry', + 'point' => 'geometry', + 'datetime' => 'timestamp', + 'year' => 'integer', + 'enum' => 'varchar', + 'set' => 'varchar', + 'json' => 'clob', + ], + 'pgsql' => [ + 'bigserial' => 'bigint', + 'bit varying' => 'bit', + 'box' => 'geometry', + 'bytea' => 'blob', + 'bpchar' => 'char', + 'character varying' => 'varchar', + 'character' => 'char', + 'cidr' => 'varchar', + 'circle' => 'geometry', + 'double precision' => 'double', + 'inet' => 'integer', + //'interval [ fields ]' + 'json' => 'clob', + 'jsonb' => 'clob', + 'line' => 'geometry', + 'lseg' => 'geometry', + 'macaddr' => 'varchar', + 'money' => 'decimal', + 'path' => 'geometry', + 'point' => 'geometry', + 'polygon' => 'geometry', + 'real' => 'float', + 'serial' => 'integer', + 'text' => 'clob', + 'time without time zone' => 'time', + 'time with time zone' => 'time_with_timezone', + 'timestamp without time zone' => 'timestamp', + 'timestamp with time zone' => 'timestamp_with_timezone', + //'tsquery'= + //'tsvector' + //'txid_snapshot' + 'uuid' => 'char', + 'xml' => 'clob', + ], + // source: https://docs.microsoft.com/en-us/sql/connect/jdbc/using-basic-data-types?view=sql-server-2017 + 'sqlsrv' => [ + 'varbinary()' => 'blob', + 'bit' => 'boolean', + 'datetime' => 'timestamp', + 'datetime2' => 'timestamp', + 'float' => 'double', + 'image' => 'blob', + 'int' => 'integer', + 'money' => 'decimal', + 'ntext' => 'clob', + 'smalldatetime' => 'timestamp', + 'smallmoney' => 'decimal', + 'text' => 'clob', + 'timestamp' => 'binary', + 'udt' => 'varbinary', + 'uniqueidentifier' => 'char', + 'xml' => 'clob', + ], + 'sqlite' => [ + 'tinytext' => 'clob', + 'text' => 'clob', + 'mediumtext' => 'clob', + 'longtext' => 'clob', + 'mediumint' => 'integer', + 'int' => 'integer', + 'bigint' => 'bigint', + 'int2' => 'smallint', + 'int4' => 'integer', + 'int8' => 'bigint', + 'double precision' => 'double', + 'datetime' => 'timestamp' + ], + ]; + + // source: https://docs.oracle.com/javase/9/docs/api/java/sql/Types.html + private $valid = [ + //'array' => true, + 'bigint' => true, + 'binary' => true, + 'bit' => true, + 'blob' => true, + 'boolean' => true, + 'char' => true, + 'clob' => true, + //'datalink' => true, + 'date' => true, + 'decimal' => true, + //'distinct' => true, + 'double' => true, + 'float' => true, + 'integer' => true, + //'java_object' => true, + 'longnvarchar' => true, + 'longvarbinary' => true, + 'longvarchar' => true, + 'nchar' => true, + 'nclob' => true, + //'null' => true, + 'numeric' => true, + 'nvarchar' => true, + //'other' => true, + 'real' => true, + //'ref' => true, + //'ref_cursor' => true, + //'rowid' => true, + 'smallint' => true, + //'sqlxml' => true, + //'struct' => true, + 'time' => true, + 'time_with_timezone' => true, + 'timestamp' => true, + 'timestamp_with_timezone' => true, + 'tinyint' => true, + 'varbinary' => true, + 'varchar' => true, + // extra: + 'geometry' => true, + ]; + + public function toJdbc(string $type, string $size): string + { + $jdbcType = strtolower($type); + if (isset($this->toJdbc[$this->driver]["$jdbcType($size)"])) { + $jdbcType = $this->toJdbc[$this->driver]["$jdbcType($size)"]; + } + if (isset($this->toJdbc[$this->driver][$jdbcType])) { + $jdbcType = $this->toJdbc[$this->driver][$jdbcType]; + } + if (isset($this->toJdbc['simplified'][$jdbcType])) { + $jdbcType = $this->toJdbc['simplified'][$jdbcType]; + } + if (!isset($this->valid[$jdbcType])) { + //throw new \Exception("Unsupported type '$jdbcType' for driver '$this->driver'"); + $jdbcType = 'clob'; + } + return $jdbcType; + } + + public function fromJdbc(string $type): string + { + $jdbcType = strtolower($type); + if (isset($this->fromJdbc[$this->driver][$jdbcType])) { + $jdbcType = $this->fromJdbc[$this->driver][$jdbcType]; + } + return $jdbcType; + } +} diff --git a/src/Tqdev/PhpCrudApi/GeoJson/Feature.php b/src/Tqdev/PhpCrudApi/GeoJson/Feature.php new file mode 100644 index 0000000..9323135 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/GeoJson/Feature.php @@ -0,0 +1,32 @@ +id = $id; + $this->properties = $properties; + $this->geometry = $geometry; + } + + public function serialize() + { + return [ + 'type' => 'Feature', + 'id' => $this->id, + 'properties' => $this->properties, + 'geometry' => $this->geometry, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } +} diff --git a/src/Tqdev/PhpCrudApi/GeoJson/FeatureCollection.php b/src/Tqdev/PhpCrudApi/GeoJson/FeatureCollection.php new file mode 100644 index 0000000..3b2212c --- /dev/null +++ b/src/Tqdev/PhpCrudApi/GeoJson/FeatureCollection.php @@ -0,0 +1,32 @@ +features = $features; + $this->results = $results; + } + + public function serialize() + { + return [ + 'type' => 'FeatureCollection', + 'features' => $this->features, + 'results' => $this->results, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize(), function ($v) { + return $v !== 0; + }); + } +} diff --git a/src/Tqdev/PhpCrudApi/GeoJson/GeoJsonService.php b/src/Tqdev/PhpCrudApi/GeoJson/GeoJsonService.php new file mode 100644 index 0000000..1fb7293 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/GeoJson/GeoJsonService.php @@ -0,0 +1,127 @@ +reflection = $reflection; + $this->records = $records; + } + + public function hasTable(string $table): bool + { + return $this->reflection->hasTable($table); + } + + public function getType(string $table): string + { + return $this->reflection->getType($table); + } + + private function getGeometryColumnName(string $tableName, array &$params): string + { + $geometryParam = isset($params['geometry']) ? $params['geometry'][0] : ''; + $table = $this->reflection->getTable($tableName); + $geometryColumnName = ''; + foreach ($table->getColumnNames() as $columnName) { + if ($geometryParam && $geometryParam != $columnName) { + continue; + } + $column = $table->getColumn($columnName); + if ($column->isGeometry()) { + $geometryColumnName = $columnName; + break; + } + } + if ($geometryColumnName) { + $params['mandatory'][] = $tableName . "." . $geometryColumnName; + } + return $geometryColumnName; + } + + private function setBoudingBoxFilter(string $geometryColumnName, array &$params) + { + $boundingBox = isset($params['bbox']) ? $params['bbox'][0] : ''; + if ($boundingBox) { + $c = explode(',', $boundingBox); + if (!isset($params['filter'])) { + $params['filter'] = array(); + } + $params['filter'][] = "$geometryColumnName,sin,POLYGON(($c[0] $c[1],$c[2] $c[1],$c[2] $c[3],$c[0] $c[3],$c[0] $c[1]))"; + } + $tile = isset($params['tile']) ? $params['tile'][0] : ''; + if ($tile) { + $zxy = explode(',', $tile); + if (count($zxy) == 3) { + list($z, $x, $y) = $zxy; + $c = array(); + $c = array_merge($c, $this->convertTileToLatLonOfUpperLeftCorner($z, $x, $y)); + $c = array_merge($c, $this->convertTileToLatLonOfUpperLeftCorner($z, $x + 1, $y + 1)); + $params['filter'][] = "$geometryColumnName,sin,POLYGON(($c[0] $c[1],$c[2] $c[1],$c[2] $c[3],$c[0] $c[3],$c[0] $c[1]))"; + } + } + } + + private function convertTileToLatLonOfUpperLeftCorner($z, $x, $y): array + { + $n = pow(2, $z); + $lon = $x / $n * 360.0 - 180.0; + $lat = rad2deg(atan(sinh(pi() * (1 - 2 * $y / $n)))); + return [$lon, $lat]; + } + + private function convertRecordToFeature(/*object*/$record, string $primaryKeyColumnName, string $geometryColumnName) + { + $id = null; + if ($primaryKeyColumnName) { + $id = $record[$primaryKeyColumnName]; + } + $geometry = null; + if (isset($record[$geometryColumnName])) { + $geometry = Geometry::fromWkt($record[$geometryColumnName]); + } + $properties = array_diff_key($record, [$primaryKeyColumnName => true, $geometryColumnName => true]); + return new Feature($id, $properties, $geometry); + } + + private function getPrimaryKeyColumnName(string $tableName, array &$params): string + { + $primaryKeyColumn = $this->reflection->getTable($tableName)->getPk(); + if (!$primaryKeyColumn) { + return ''; + } + $primaryKeyColumnName = $primaryKeyColumn->getName(); + $params['mandatory'][] = $tableName . "." . $primaryKeyColumnName; + return $primaryKeyColumnName; + } + + public function _list(string $tableName, array $params): FeatureCollection + { + $geometryColumnName = $this->getGeometryColumnName($tableName, $params); + $this->setBoudingBoxFilter($geometryColumnName, $params); + $primaryKeyColumnName = $this->getPrimaryKeyColumnName($tableName, $params); + $records = $this->records->_list($tableName, $params); + $features = array(); + foreach ($records->getRecords() as $record) { + $features[] = $this->convertRecordToFeature($record, $primaryKeyColumnName, $geometryColumnName); + } + return new FeatureCollection($features, $records->getResults()); + } + + public function read(string $tableName, string $id, array $params): Feature + { + $geometryColumnName = $this->getGeometryColumnName($tableName, $params); + $primaryKeyColumnName = $this->getPrimaryKeyColumnName($tableName, $params); + $record = $this->records->read($tableName, $id, $params); + return $this->convertRecordToFeature($record, $primaryKeyColumnName, $geometryColumnName); + } +} diff --git a/src/Tqdev/PhpCrudApi/GeoJson/Geometry.php b/src/Tqdev/PhpCrudApi/GeoJson/Geometry.php new file mode 100644 index 0000000..f035d77 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/GeoJson/Geometry.php @@ -0,0 +1,64 @@ +type = $type; + $this->coordinates = $coordinates; + } + + public static function fromWkt(string $wkt): Geometry + { + $bracket = strpos($wkt, '('); + $type = strtoupper(trim(substr($wkt, 0, $bracket))); + $supported = false; + foreach (Geometry::$types as $typeName) { + if (strtoupper($typeName) == $type) { + $type = $typeName; + $supported = true; + } + } + if (!$supported) { + throw new \Exception('Geometry type not supported: ' . $type); + } + $coordinates = substr($wkt, $bracket); + if (substr($type, -5) != 'Point' || ($type == 'MultiPoint' && $coordinates[1] != '(')) { + $coordinates = preg_replace('|([0-9\-\.]+ )+([0-9\-\.]+)|', '[\1\2]', $coordinates); + } + $coordinates = str_replace(['(', ')', ', ', ' '], ['[', ']', ',', ','], $coordinates); + $coordinates = json_decode($coordinates); + if (!$coordinates) { + throw new \Exception('Could not decode WKT: ' . $wkt); + } + return new Geometry($type, $coordinates); + } + + public function serialize() + { + return [ + 'type' => $this->type, + 'coordinates' => $this->coordinates, + ]; + } + + public function jsonSerialize() + { + return $this->serialize(); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/AjaxOnlyMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/AjaxOnlyMiddleware.php new file mode 100644 index 0000000..bc650dc --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/AjaxOnlyMiddleware.php @@ -0,0 +1,28 @@ +getMethod(); + $excludeMethods = $this->getArrayProperty('excludeMethods', 'OPTIONS,GET'); + if (!in_array($method, $excludeMethods)) { + $headerName = $this->getProperty('headerName', 'X-Requested-With'); + $headerValue = $this->getProperty('headerValue', 'XMLHttpRequest'); + if ($headerValue != RequestUtils::getHeader($request, $headerName)) { + return $this->responder->error(ErrorCode::ONLY_AJAX_REQUESTS_ALLOWED, $method); + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/AuthorizationMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/AuthorizationMiddleware.php new file mode 100644 index 0000000..150fec4 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/AuthorizationMiddleware.php @@ -0,0 +1,103 @@ +reflection = $reflection; + } + + private function handleColumns(string $operation, string $tableName) /*: void*/ + { + $columnHandler = $this->getProperty('columnHandler', ''); + if ($columnHandler) { + $table = $this->reflection->getTable($tableName); + foreach ($table->getColumnNames() as $columnName) { + $allowed = call_user_func($columnHandler, $operation, $tableName, $columnName); + if (!$allowed) { + $table->removeColumn($columnName); + } + } + } + } + + private function handleTable(string $operation, string $tableName) /*: void*/ + { + if (!$this->reflection->hasTable($tableName)) { + return; + } + $allowed = true; + $tableHandler = $this->getProperty('tableHandler', ''); + if ($tableHandler) { + $allowed = call_user_func($tableHandler, $operation, $tableName); + } + if (!$allowed) { + $this->reflection->removeTable($tableName); + } else { + $this->handleColumns($operation, $tableName); + } + } + + private function handleRecords(string $operation, string $tableName) /*: void*/ + { + if (!$this->reflection->hasTable($tableName)) { + return; + } + $recordHandler = $this->getProperty('recordHandler', ''); + if ($recordHandler) { + $query = call_user_func($recordHandler, $operation, $tableName); + $filters = new FilterInfo(); + $table = $this->reflection->getTable($tableName); + $query = str_replace('][]=', ']=', str_replace('=', '[]=', $query)); + parse_str($query, $params); + $condition = $filters->getCombinedConditions($table, $params); + VariableStore::set("authorization.conditions.$tableName", $condition); + } + } + + private function pathHandler(string $path) /*: bool*/ + { + $pathHandler = $this->getProperty('pathHandler', ''); + return $pathHandler ? call_user_func($pathHandler, $path) : true; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $path = RequestUtils::getPathSegment($request, 1); + + if (!$this->pathHandler($path)) { + return $this->responder->error(ErrorCode::ROUTE_NOT_FOUND, $request->getUri()->getPath()); + } + + $operation = RequestUtils::getOperation($request); + $tableNames = RequestUtils::getTableNames($request, $this->reflection); + foreach ($tableNames as $tableName) { + $this->handleTable($operation, $tableName); + if ($path == 'records') { + $this->handleRecords($operation, $tableName); + } + } + if ($path == 'openapi') { + VariableStore::set('authorization.tableHandler', $this->getProperty('tableHandler', '')); + VariableStore::set('authorization.columnHandler', $this->getProperty('columnHandler', '')); + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/Base/Middleware.php b/src/Tqdev/PhpCrudApi/Middleware/Base/Middleware.php new file mode 100644 index 0000000..6a596b0 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/Base/Middleware.php @@ -0,0 +1,46 @@ +load($this); + $this->responder = $responder; + $this->properties = $properties; + } + + protected function getArrayProperty(string $key, string $default): array + { + return array_filter(array_map('trim', explode(',', $this->getProperty($key, $default)))); + } + + protected function getMapProperty(string $key, string $default): array + { + $pairs = $this->getArrayProperty($key, $default); + $result = array(); + foreach ($pairs as $pair) { + if (strpos($pair, ':')) { + list($k, $v) = explode(':', $pair, 2); + $result[trim($k)] = trim($v); + } else { + $result[] = trim($pair); + } + } + return $result; + } + + protected function getProperty(string $key, $default) + { + return isset($this->properties[$key]) ? $this->properties[$key] : $default; + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php new file mode 100644 index 0000000..511262c --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php @@ -0,0 +1,118 @@ +readPasswords($passwordFile); + $valid = $this->hasCorrectPassword($username, $password, $passwords); + $this->writePasswords($passwordFile, $passwords); + return $valid ? $username : ''; + } + + private function readPasswords(string $passwordFile): array + { + $passwords = []; + $passwordLines = file($passwordFile); + foreach ($passwordLines as $passwordLine) { + if (strpos($passwordLine, ':') !== false) { + list($username, $hash) = explode(':', trim($passwordLine), 2); + if (strlen($hash) > 0 && $hash[0] != '$') { + $hash = password_hash($hash, PASSWORD_DEFAULT); + } + $passwords[$username] = $hash; + } + } + return $passwords; + } + + private function writePasswords(string $passwordFile, array $passwords): bool + { + $success = false; + $passwordFileContents = ''; + foreach ($passwords as $username => $hash) { + $passwordFileContents .= "$username:$hash\n"; + } + if (file_get_contents($passwordFile) != $passwordFileContents) { + $success = file_put_contents($passwordFile, $passwordFileContents) !== false; + } + return $success; + } + + private function getAuthorizationCredentials(ServerRequestInterface $request): string + { + if (isset($_SERVER['PHP_AUTH_USER'])) { + return $_SERVER['PHP_AUTH_USER'] . ':' . $_SERVER['PHP_AUTH_PW']; + } + $header = RequestUtils::getHeader($request, 'Authorization'); + $parts = explode(' ', trim($header), 2); + if (count($parts) != 2) { + return ''; + } + if ($parts[0] != 'Basic') { + return ''; + } + return base64_decode(strtr($parts[1], '-_', '+/')); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $credentials = $this->getAuthorizationCredentials($request); + if ($credentials) { + list($username, $password) = array('', ''); + if (strpos($credentials, ':') !== false) { + list($username, $password) = explode(':', $credentials, 2); + } + $passwordFile = $this->getProperty('passwordFile', '.htpasswd'); + $validUser = $this->getValidUsername($username, $password, $passwordFile); + $_SESSION['username'] = $validUser; + if (!$validUser) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (!headers_sent()) { + session_regenerate_id(); + } + } + if (!isset($_SESSION['username']) || !$_SESSION['username']) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + $response = $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + $realm = $this->getProperty('realm', 'Username and password required'); + $response = $response->withHeader('WWW-Authenticate', "Basic realm=\"$realm\""); + return $response; + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/Communication/VariableStore.php b/src/Tqdev/PhpCrudApi/Middleware/Communication/VariableStore.php new file mode 100644 index 0000000..49ea8f4 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/Communication/VariableStore.php @@ -0,0 +1,21 @@ +debug = $debug; + } + + private function isOriginAllowed(string $origin, string $allowedOrigins): bool + { + $found = false; + foreach (explode(',', $allowedOrigins) as $allowedOrigin) { + $hostname = preg_quote(strtolower(trim($allowedOrigin))); + $regex = '/^' . str_replace('\*', '.*', $hostname) . '$/'; + if (preg_match($regex, $origin)) { + $found = true; + break; + } + } + return $found; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $method = $request->getMethod(); + $origin = count($request->getHeader('Origin')) ? $request->getHeader('Origin')[0] : ''; + $allowedOrigins = $this->getProperty('allowedOrigins', '*'); + if ($origin && !$this->isOriginAllowed($origin, $allowedOrigins)) { + $response = $this->responder->error(ErrorCode::ORIGIN_FORBIDDEN, $origin); + } elseif ($method == 'OPTIONS') { + $response = ResponseFactory::fromStatus(ResponseFactory::OK); + $allowHeaders = $this->getProperty('allowHeaders', 'Content-Type, X-XSRF-TOKEN, X-Authorization'); + if ($this->debug) { + $allowHeaders = implode(', ', array_filter([$allowHeaders, 'X-Exception-Name, X-Exception-Message, X-Exception-File'])); + } + if ($allowHeaders) { + $response = $response->withHeader('Access-Control-Allow-Headers', $allowHeaders); + } + $allowMethods = $this->getProperty('allowMethods', 'OPTIONS, GET, PUT, POST, DELETE, PATCH'); + if ($allowMethods) { + $response = $response->withHeader('Access-Control-Allow-Methods', $allowMethods); + } + $allowCredentials = $this->getProperty('allowCredentials', 'true'); + if ($allowCredentials) { + $response = $response->withHeader('Access-Control-Allow-Credentials', $allowCredentials); + } + $maxAge = $this->getProperty('maxAge', '1728000'); + if ($maxAge) { + $response = $response->withHeader('Access-Control-Max-Age', $maxAge); + } + $exposeHeaders = $this->getProperty('exposeHeaders', ''); + if ($this->debug) { + $exposeHeaders = implode(', ', array_filter([$exposeHeaders, 'X-Exception-Name, X-Exception-Message, X-Exception-File'])); + } + if ($exposeHeaders) { + $response = $response->withHeader('Access-Control-Expose-Headers', $exposeHeaders); + } + } else { + $response = null; + try { + $response = $next->handle($request); + } catch (\Throwable $e) { + $response = $this->responder->error(ErrorCode::ERROR_NOT_FOUND, $e->getMessage()); + if ($this->debug) { + $response = ResponseUtils::addExceptionHeaders($response, $e); + } + } + } + if ($origin) { + $allowCredentials = $this->getProperty('allowCredentials', 'true'); + if ($allowCredentials) { + $response = $response->withHeader('Access-Control-Allow-Credentials', $allowCredentials); + } + $response = $response->withHeader('Access-Control-Allow-Origin', $origin); + } + return $response; + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/CustomizationMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/CustomizationMiddleware.php new file mode 100644 index 0000000..5d61889 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/CustomizationMiddleware.php @@ -0,0 +1,42 @@ +reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + $tableName = RequestUtils::getPathSegment($request, 2); + $beforeHandler = $this->getProperty('beforeHandler', ''); + $environment = (object) array(); + if ($beforeHandler !== '') { + $result = call_user_func($beforeHandler, $operation, $tableName, $request, $environment); + $request = $result ?: $request; + } + $response = $next->handle($request); + $afterHandler = $this->getProperty('afterHandler', ''); + if ($afterHandler !== '') { + $result = call_user_func($afterHandler, $operation, $tableName, $response, $environment); + $response = $result ?: $response; + } + return $response; + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php new file mode 100644 index 0000000..91d4180 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php @@ -0,0 +1,152 @@ +reflection = $reflection; + $this->db = $db; + $this->ordering = new OrderingInfo(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $path = RequestUtils::getPathSegment($request, 1); + $method = $request->getMethod(); + if ($method == 'POST' && in_array($path, ['login', 'register', 'password'])) { + $body = $request->getParsedBody(); + $username = isset($body->username) ? $body->username : ''; + $password = isset($body->password) ? $body->password : ''; + $newPassword = isset($body->newPassword) ? $body->newPassword : ''; + $tableName = $this->getProperty('usersTable', 'users'); + $table = $this->reflection->getTable($tableName); + $usernameColumnName = $this->getProperty('usernameColumn', 'username'); + $usernameColumn = $table->getColumn($usernameColumnName); + $passwordColumnName = $this->getProperty('passwordColumn', 'password'); + $passwordLength = $this->getProperty('passwordLength', '12'); + $pkName = $table->getPk()->getName(); + $registerUser = $this->getProperty('registerUser', ''); + $condition = new ColumnCondition($usernameColumn, 'eq', $username); + $returnedColumns = $this->getProperty('returnedColumns', ''); + if (!$returnedColumns) { + $columnNames = $table->getColumnNames(); + } else { + $columnNames = array_map('trim', explode(',', $returnedColumns)); + $columnNames[] = $passwordColumnName; + $columnNames[] = $pkName; + } + $columnOrdering = $this->ordering->getDefaultColumnOrdering($table); + if ($path == 'register') { + if (!$registerUser) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (strlen($password) < $passwordLength) { + return $this->responder->error(ErrorCode::PASSWORD_TOO_SHORT, $passwordLength); + } + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + if (!empty($users)) { + return $this->responder->error(ErrorCode::USER_ALREADY_EXIST, $username); + } + $data = json_decode($registerUser, true); + $data = is_array($data) ? $data : []; + $data[$usernameColumnName] = $username; + $data[$passwordColumnName] = password_hash($password, PASSWORD_DEFAULT); + $this->db->createSingle($table, $data); + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if ($path == 'login') { + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + if (password_verify($password, $user[$passwordColumnName]) == 1) { + if (!headers_sent()) { + session_regenerate_id(true); + } + unset($user[$passwordColumnName]); + $_SESSION['user'] = $user; + return $this->responder->success($user); + } + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if ($path == 'password') { + if ($username != ($_SESSION['user'][$usernameColumnName] ?? '')) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + if (strlen($newPassword) < $passwordLength) { + return $this->responder->error(ErrorCode::PASSWORD_TOO_SHORT, $passwordLength); + } + $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); + foreach ($users as $user) { + if (password_verify($password, $user[$passwordColumnName]) == 1) { + if (!headers_sent()) { + session_regenerate_id(true); + } + $data = [$passwordColumnName => password_hash($newPassword, PASSWORD_DEFAULT)]; + $this->db->updateSingle($table, $data, $user[$pkName]); + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } + } + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $username); + } + } + if ($method == 'POST' && $path == 'logout') { + if (isset($_SESSION['user'])) { + $user = $_SESSION['user']; + unset($_SESSION['user']); + if (session_status() != PHP_SESSION_NONE) { + session_destroy(); + } + return $this->responder->success($user); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + if ($method == 'GET' && $path == 'me') { + if (isset($_SESSION['user'])) { + return $this->responder->success($_SESSION['user']); + } + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + if (!isset($_SESSION['user']) || !$_SESSION['user']) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/FirewallMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/FirewallMiddleware.php new file mode 100644 index 0000000..c41394c --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/FirewallMiddleware.php @@ -0,0 +1,57 @@ +ipMatch($ipAddress, $allowedIp)) { + return true; + } + } + return false; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $reverseProxy = $this->getProperty('reverseProxy', ''); + if ($reverseProxy) { + $ipAddress = array_pop(explode(',', $request->getHeader('X-Forwarded-For'))); + } elseif (isset($_SERVER['REMOTE_ADDR'])) { + $ipAddress = $_SERVER['REMOTE_ADDR']; + } else { + $ipAddress = '127.0.0.1'; + } + $allowedIpAddresses = $this->getProperty('allowedIpAddresses', ''); + if (!$this->isIpAllowed($ipAddress, $allowedIpAddresses)) { + $response = $this->responder->error(ErrorCode::TEMPORARY_OR_PERMANENTLY_BLOCKED, ''); + } else { + $response = $next->handle($request); + } + return $response; + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/IpAddressMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/IpAddressMiddleware.php new file mode 100644 index 0000000..a2eb9a2 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/IpAddressMiddleware.php @@ -0,0 +1,68 @@ +reflection = $reflection; + } + + private function callHandler($record, string $operation, ReflectedTable $table) /*: object */ + { + $context = (array) $record; + $columnNames = $this->getProperty('columns', ''); + if ($columnNames) { + foreach (explode(',', $columnNames) as $columnName) { + if ($table->hasColumn($columnName)) { + if ($operation == 'create') { + $context[$columnName] = $_SERVER['REMOTE_ADDR']; + } else { + unset($context[$columnName]); + } + } + } + } + return (object) $context; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableNames = $this->getProperty('tables', ''); + $tableName = RequestUtils::getPathSegment($request, 2); + if (!$tableNames || in_array($tableName, explode(',', $tableNames))) { + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as &$r) { + $r = $this->callHandler($r, $operation, $table); + } + } else { + $record = $this->callHandler($record, $operation, $table); + } + $request = $request->withParsedBody($record); + } + } + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/JoinLimitsMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/JoinLimitsMiddleware.php new file mode 100644 index 0000000..c1e87df --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/JoinLimitsMiddleware.php @@ -0,0 +1,56 @@ +reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + $params = RequestUtils::getParams($request); + if (in_array($operation, ['read', 'list']) && isset($params['join'])) { + $maxDepth = (int) $this->getProperty('depth', '3'); + $maxTables = (int) $this->getProperty('tables', '10'); + $maxRecords = (int) $this->getProperty('records', '1000'); + $tableCount = 0; + $joinPaths = array(); + for ($i = 0; $i < count($params['join']); $i++) { + $joinPath = array(); + $tables = explode(',', $params['join'][$i]); + for ($depth = 0; $depth < min($maxDepth, count($tables)); $depth++) { + array_push($joinPath, $tables[$depth]); + $tableCount += 1; + if ($tableCount == $maxTables) { + break; + } + } + array_push($joinPaths, implode(',', $joinPath)); + if ($tableCount == $maxTables) { + break; + } + } + $params['join'] = $joinPaths; + $request = RequestUtils::setParams($request, $params); + VariableStore::set("joinLimits.maxRecords", $maxRecords); + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php new file mode 100644 index 0000000..1b4685d --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php @@ -0,0 +1,156 @@ + 'sha256', + 'HS384' => 'sha384', + 'HS512' => 'sha512', + 'RS256' => 'sha256', + 'RS384' => 'sha384', + 'RS512' => 'sha512', + ); + $token = explode('.', $token); + if (count($token) < 3) { + return array(); + } + $header = json_decode(base64_decode(strtr($token[0], '-_', '+/')), true); + $kid = 0; + if (isset($header['kid'])) { + $kid = $header['kid']; + } + if (!isset($secrets[$kid])) { + return array(); + } + $secret = $secrets[$kid]; + if ($header['typ'] != 'JWT') { + return array(); + } + $algorithm = $header['alg']; + if (!isset($algorithms[$algorithm])) { + return array(); + } + if (!empty($requirements['alg']) && !in_array($algorithm, $requirements['alg'])) { + return array(); + } + $hmac = $algorithms[$algorithm]; + $signature = base64_decode(strtr($token[2], '-_', '+/')); + $data = "$token[0].$token[1]"; + switch ($algorithm[0]) { + case 'H': + $hash = hash_hmac($hmac, $data, $secret, true); + $equals = hash_equals($hash, $signature); + if (!$equals) { + return array(); + } + break; + case 'R': + $equals = openssl_verify($data, $signature, $secret, $hmac) == 1; + if (!$equals) { + return array(); + } + break; + } + $claims = json_decode(base64_decode(strtr($token[1], '-_', '+/')), true); + if (!$claims) { + return array(); + } + foreach ($requirements as $field => $values) { + if (!empty($values)) { + if ($field != 'alg') { + if (!isset($claims[$field]) || !in_array($claims[$field], $values)) { + return array(); + } + } + } + } + if (isset($claims['nbf']) && $time + $leeway < $claims['nbf']) { + return array(); + } + if (isset($claims['iat']) && $time + $leeway < $claims['iat']) { + return array(); + } + if (isset($claims['exp']) && $time - $leeway > $claims['exp']) { + return array(); + } + if (isset($claims['iat']) && !isset($claims['exp'])) { + if ($time - $leeway > $claims['iat'] + $ttl) { + return array(); + } + } + return $claims; + } + + private function getClaims(string $token): array + { + $time = (int) $this->getProperty('time', time()); + $leeway = (int) $this->getProperty('leeway', '5'); + $ttl = (int) $this->getProperty('ttl', '30'); + $secrets = $this->getMapProperty('secrets', ''); + if (!$secrets) { + $secrets = [$this->getProperty('secret', '')]; + } + $requirements = array( + 'alg' => $this->getArrayProperty('algorithms', ''), + 'aud' => $this->getArrayProperty('audiences', ''), + 'iss' => $this->getArrayProperty('issuers', ''), + ); + return $this->getVerifiedClaims($token, $time, $leeway, $ttl, $secrets, $requirements); + } + + private function getAuthorizationToken(ServerRequestInterface $request): string + { + $headerName = $this->getProperty('header', 'X-Authorization'); + $headerValue = RequestUtils::getHeader($request, $headerName); + $parts = explode(' ', trim($headerValue), 2); + if (count($parts) != 2) { + return ''; + } + if ($parts[0] != 'Bearer') { + return ''; + } + return $parts[1]; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + if (session_status() == PHP_SESSION_NONE) { + if (!headers_sent()) { + $sessionName = $this->getProperty('sessionName', ''); + if ($sessionName) { + session_name($sessionName); + } + session_start(); + } + } + $token = $this->getAuthorizationToken($request); + if ($token) { + $claims = $this->getClaims($token); + $_SESSION['claims'] = $claims; + if (empty($claims)) { + return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, 'JWT'); + } + if (!headers_sent()) { + session_regenerate_id(); + } + } + if (empty($_SESSION['claims'])) { + $authenticationMode = $this->getProperty('mode', 'required'); + if ($authenticationMode == 'required') { + return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/MultiTenancyMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/MultiTenancyMiddleware.php new file mode 100644 index 0000000..e595bee --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/MultiTenancyMiddleware.php @@ -0,0 +1,98 @@ +reflection = $reflection; + } + + private function getCondition(string $tableName, array $pairs): Condition + { + $condition = new NoCondition(); + $table = $this->reflection->getTable($tableName); + foreach ($pairs as $k => $v) { + $condition = $condition->_and(new ColumnCondition($table->getColumn($k), 'eq', $v)); + } + return $condition; + } + + private function getPairs($handler, string $operation, string $tableName): array + { + $result = array(); + $pairs = call_user_func($handler, $operation, $tableName) ?: []; + $table = $this->reflection->getTable($tableName); + foreach ($pairs as $k => $v) { + if ($table->hasColumn($k)) { + $result[$k] = $v; + } + } + return $result; + } + + private function handleRecord(ServerRequestInterface $request, string $operation, array $pairs): ServerRequestInterface + { + $record = $request->getParsedBody(); + if ($record === null) { + return $request; + } + $multi = is_array($record); + $records = $multi ? $record : [$record]; + foreach ($records as &$record) { + foreach ($pairs as $column => $value) { + if ($operation == 'create') { + $record->$column = $value; + } else { + if (isset($record->$column)) { + unset($record->$column); + } + } + } + } + return $request->withParsedBody($multi ? $records : $records[0]); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $path = RequestUtils::getPathSegment($request, 1); + if ($path == 'records') { + $operation = RequestUtils::getOperation($request); + $tableNames = RequestUtils::getTableNames($request, $this->reflection); + foreach ($tableNames as $i => $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $pairs = $this->getPairs($handler, $operation, $tableName); + if ($i == 0) { + if (in_array($operation, ['create', 'update', 'increment'])) { + $request = $this->handleRecord($request, $operation, $pairs); + } + } + $condition = $this->getCondition($tableName, $pairs); + VariableStore::set("multiTenancy.conditions.$tableName", $condition); + } + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/PageLimitsMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/PageLimitsMiddleware.php new file mode 100644 index 0000000..abbb7e6 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/PageLimitsMiddleware.php @@ -0,0 +1,51 @@ +reflection = $reflection; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if ($operation == 'list') { + $params = RequestUtils::getParams($request); + $maxPage = (int) $this->getProperty('pages', '100'); + if (isset($params['page']) && $params['page'] && $maxPage > 0) { + if (strpos($params['page'][0], ',') === false) { + $page = $params['page'][0]; + } else { + list($page, $size) = explode(',', $params['page'][0], 2); + } + if ($page > $maxPage) { + return $this->responder->error(ErrorCode::PAGINATION_FORBIDDEN, ''); + } + } + $maxSize = (int) $this->getProperty('records', '1000'); + if (!isset($params['size']) || !$params['size'] && $maxSize > 0) { + $params['size'] = array($maxSize); + } else { + $params['size'] = array(min($params['size'][0], $maxSize)); + } + $request = RequestUtils::setParams($request, $params); + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/ReconnectMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/ReconnectMiddleware.php new file mode 100644 index 0000000..a56262e --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/ReconnectMiddleware.php @@ -0,0 +1,103 @@ +reflection = $reflection; + $this->db = $db; + } + + private function getDriver(): string + { + $driverHandler = $this->getProperty('driverHandler', ''); + if ($driverHandler) { + return call_user_func($driverHandler); + } + return ''; + } + + private function getAddress(): string + { + $addressHandler = $this->getProperty('addressHandler', ''); + if ($addressHandler) { + return call_user_func($addressHandler); + } + return ''; + } + + private function getPort(): int + { + $portHandler = $this->getProperty('portHandler', ''); + if ($portHandler) { + return call_user_func($portHandler); + } + return 0; + } + + private function getDatabase(): string + { + $databaseHandler = $this->getProperty('databaseHandler', ''); + if ($databaseHandler) { + return call_user_func($databaseHandler); + } + return ''; + } + + private function getTables(): array + { + $tablesHandler = $this->getProperty('tablesHandler', ''); + if ($tablesHandler) { + return call_user_func($tablesHandler); + } + return []; + } + + private function getUsername(): string + { + $usernameHandler = $this->getProperty('usernameHandler', ''); + if ($usernameHandler) { + return call_user_func($usernameHandler); + } + return ''; + } + + private function getPassword(): string + { + $passwordHandler = $this->getProperty('passwordHandler', ''); + if ($passwordHandler) { + return call_user_func($passwordHandler); + } + return ''; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $driver = $this->getDriver(); + $address = $this->getAddress(); + $port = $this->getPort(); + $database = $this->getDatabase(); + $tables = $this->getTables(); + $username = $this->getUsername(); + $password = $this->getPassword(); + if ($driver || $address || $port || $database || $tables || $username || $password) { + $this->db->reconstruct($driver, $address, $port, $database, $tables, $username, $password); + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/Router/Router.php b/src/Tqdev/PhpCrudApi/Middleware/Router/Router.php new file mode 100644 index 0000000..d033b98 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/Router/Router.php @@ -0,0 +1,17 @@ +basePath = rtrim($this->detectBasePath($basePath), '/'); + $this->responder = $responder; + $this->cache = $cache; + $this->ttl = $ttl; + $this->debug = $debug; + $this->registration = true; + $this->routes = $this->loadPathTree(); + $this->routeHandlers = []; + $this->middlewares = array(); + } + + private function detectBasePath(string $basePath): string + { + if ($basePath) { + return $basePath; + } + if (isset($_SERVER['REQUEST_URI'])) { + $fullPath = urldecode(explode('?', $_SERVER['REQUEST_URI'])[0]); + if (isset($_SERVER['PATH_INFO'])) { + $path = $_SERVER['PATH_INFO']; + if (substr($fullPath, -1 * strlen($path)) == $path) { + return substr($fullPath, 0, -1 * strlen($path)); + } + } + if ('/' . basename(__FILE__) == $fullPath) { + return $fullPath; + } + } + return '/'; + } + + private function loadPathTree(): PathTree + { + $data = $this->cache->get('PathTree'); + if ($data != '') { + $tree = PathTree::fromJson(json_decode(gzuncompress($data))); + $this->registration = false; + } else { + $tree = new PathTree(); + } + return $tree; + } + + public function register(string $method, string $path, array $handler) + { + $routeNumber = count($this->routeHandlers); + $this->routeHandlers[$routeNumber] = $handler; + if ($this->registration) { + $path = trim($path, '/'); + $parts = array(); + if ($path) { + $parts = explode('/', $path); + } + array_unshift($parts, $method); + $this->routes->put($parts, $routeNumber); + } + } + + public function load(Middleware $middleware) /*: void*/ + { + array_push($this->middlewares, $middleware); + } + + public function route(ServerRequestInterface $request): ResponseInterface + { + if ($this->registration) { + $data = gzcompress(json_encode($this->routes, JSON_UNESCAPED_UNICODE)); + $this->cache->set('PathTree', $data, $this->ttl); + } + + return $this->handle($request); + } + + private function getRouteNumbers(ServerRequestInterface $request): array + { + $method = strtoupper($request->getMethod()); + $path = array(); + $segment = $method; + for ($i = 1; strlen($segment) > 0; $i++) { + array_push($path, $segment); + $segment = RequestUtils::getPathSegment($request, $i); + } + return $this->routes->match($path); + } + + private function removeBasePath(ServerRequestInterface $request): ServerRequestInterface + { + $path = $request->getUri()->getPath(); + if (substr($path, 0, strlen($this->basePath)) == $this->basePath) { + $path = substr($path, strlen($this->basePath)); + $request = $request->withUri($request->getUri()->withPath($path)); + } + return $request; + } + + public function getBasePath(): string + { + return $this->basePath; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $request = $this->removeBasePath($request); + + if (count($this->middlewares)) { + $handler = array_pop($this->middlewares); + return $handler->process($request, $this); + } + + $routeNumbers = $this->getRouteNumbers($request); + if (count($routeNumbers) == 0) { + return $this->responder->error(ErrorCode::ROUTE_NOT_FOUND, $request->getUri()->getPath()); + } + try { + $response = call_user_func($this->routeHandlers[$routeNumbers[0]], $request); + } catch (\PDOException $e) { + if (strpos(strtolower($e->getMessage()), 'duplicate') !== false) { + $response = $this->responder->error(ErrorCode::DUPLICATE_KEY_EXCEPTION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'unique constraint') !== false) { + $response = $this->responder->error(ErrorCode::DUPLICATE_KEY_EXCEPTION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'default value') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'allow nulls') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } elseif (strpos(strtolower($e->getMessage()), 'constraint') !== false) { + $response = $this->responder->error(ErrorCode::DATA_INTEGRITY_VIOLATION, ''); + } else { + $response = $this->responder->error(ErrorCode::ERROR_NOT_FOUND, ''); + } + if ($this->debug) { + $response = ResponseUtils::addExceptionHeaders($response, $e); + } + } + return $response; + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php new file mode 100644 index 0000000..c044f80 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php @@ -0,0 +1,150 @@ +reflection = $reflection; + } + + private function callHandler($handler, $record, string $operation, ReflectedTable $table) /*: object */ + { + $context = (array) $record; + $tableName = $table->getName(); + foreach ($context as $columnName => &$value) { + if ($table->hasColumn($columnName)) { + $column = $table->getColumn($columnName); + $value = call_user_func($handler, $operation, $tableName, $column->serialize(), $value); + $value = $this->sanitizeType($table, $column, $value); + } + } + return (object) $context; + } + + private function sanitizeType(ReflectedTable $table, ReflectedColumn $column, $value) + { + $tables = $this->getArrayProperty('tables', 'all'); + $types = $this->getArrayProperty('types', 'all'); + if ( + (in_array('all', $tables) || in_array($table->getName(), $tables)) && + (in_array('all', $types) || in_array($column->getType(), $types)) + ) { + if (is_null($value)) { + return $value; + } + if (is_string($value)) { + $newValue = null; + switch ($column->getType()) { + case 'integer': + case 'bigint': + $newValue = filter_var(trim($value), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + break; + case 'decimal': + $newValue = filter_var(trim($value), FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + if (is_float($newValue)) { + $newValue = number_format($newValue, $column->getScale(), '.', ''); + } + break; + case 'float': + case 'double': + $newValue = filter_var(trim($value), FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + break; + case 'boolean': + $newValue = filter_var(trim($value), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + break; + case 'date': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('Y-m-d', $time); + } + break; + case 'time': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('H:i:s', $time); + } + break; + case 'timestamp': + $time = strtotime(trim($value)); + if ($time !== false) { + $newValue = date('Y-m-d H:i:s', $time); + } + break; + case 'blob': + case 'varbinary': + // allow base64url format + $newValue = strtr(trim($value), '-_', '+/'); + break; + case 'clob': + case 'varchar': + $newValue = $value; + break; + case 'geometry': + $newValue = trim($value); + break; + } + if (!is_null($newValue)) { + $value = $newValue; + } + } else { + switch ($column->getType()) { + case 'integer': + case 'bigint': + if (is_float($value)) { + $value = (int) round($value); + } + break; + case 'decimal': + if (is_float($value) || is_int($value)) { + $value = number_format((float) $value, $column->getScale(), '.', ''); + } + break; + } + } + // post process + } + return $value; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableName = RequestUtils::getPathSegment($request, 2); + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as &$r) { + $r = $this->callHandler($handler, $r, $operation, $table); + } + } else { + $record = $this->callHandler($handler, $record, $operation, $table); + } + $request = $request->withParsedBody($record); + } + } + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/SslRedirectMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/SslRedirectMiddleware.php new file mode 100644 index 0000000..88bf523 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/SslRedirectMiddleware.php @@ -0,0 +1,27 @@ +getUri(); + $scheme = $uri->getScheme(); + if ($scheme == 'http') { + $uri = $request->getUri(); + $uri = $uri->withScheme('https'); + $response = ResponseFactory::fromStatus(301); + $response = $response->withHeader('Location', $uri->__toString()); + } else { + $response = $next->handle($request); + } + return $response; + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php new file mode 100644 index 0000000..f0562e7 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php @@ -0,0 +1,217 @@ +reflection = $reflection; + } + + private function callHandler($handler, $record, string $operation, ReflectedTable $table) /*: ResponseInterface?*/ + { + $context = (array) $record; + $details = array(); + $tableName = $table->getName(); + foreach ($context as $columnName => $value) { + if ($table->hasColumn($columnName)) { + $column = $table->getColumn($columnName); + $valid = call_user_func($handler, $operation, $tableName, $column->serialize(), $value, $context); + if ($valid === true || $valid === '') { + $valid = $this->validateType($table, $column, $value); + } + if ($valid !== true && $valid !== '') { + $details[$columnName] = $valid; + } + } + } + if (count($details) > 0) { + return $this->responder->error(ErrorCode::INPUT_VALIDATION_FAILED, $tableName, $details); + } + return null; + } + + private function validateType(ReflectedTable $table, ReflectedColumn $column, $value) + { + $tables = $this->getArrayProperty('tables', 'all'); + $types = $this->getArrayProperty('types', 'all'); + if ( + (in_array('all', $tables) || in_array($table->getName(), $tables)) && + (in_array('all', $types) || in_array($column->getType(), $types)) + ) { + if (is_null($value)) { + return ($column->getNullable() ? true : "cannot be null"); + } + if (is_string($value)) { + // check for whitespace + switch ($column->getType()) { + case 'varchar': + case 'clob': + break; + default: + if (strlen(trim($value)) != strlen($value)) { + return 'illegal whitespace'; + } + break; + } + // try to parse + switch ($column->getType()) { + case 'integer': + case 'bigint': + if ( + filter_var($value, FILTER_SANITIZE_NUMBER_INT) !== $value || + filter_var($value, FILTER_VALIDATE_INT) === false + ) { + return 'invalid integer'; + } + break; + case 'decimal': + if (strpos($value, '.') !== false) { + list($whole, $decimals) = explode('.', ltrim($value, '-'), 2); + } else { + list($whole, $decimals) = array(ltrim($value, '-'), ''); + } + if (strlen($whole) > 0 && !ctype_digit($whole)) { + return 'invalid decimal'; + } + if (strlen($decimals) > 0 && !ctype_digit($decimals)) { + return 'invalid decimal'; + } + if (strlen($whole) > $column->getPrecision() - $column->getScale()) { + return 'decimal too large'; + } + if (strlen($decimals) > $column->getScale()) { + return 'decimal too precise'; + } + break; + case 'float': + case 'double': + if ( + filter_var($value, FILTER_SANITIZE_NUMBER_FLOAT) !== $value || + filter_var($value, FILTER_VALIDATE_FLOAT) === false + ) { + return 'invalid float'; + } + break; + case 'boolean': + if (!in_array(strtolower($value), array('true', 'false'))) { + return 'invalid boolean'; + } + break; + case 'date': + if (date_create_from_format('Y-m-d', $value) === false) { + return 'invalid date'; + } + break; + case 'time': + if (date_create_from_format('H:i:s', $value) === false) { + return 'invalid time'; + } + break; + case 'timestamp': + if (date_create_from_format('Y-m-d H:i:s', $value) === false) { + return 'invalid timestamp'; + } + break; + case 'clob': + case 'varchar': + if ($column->hasLength() && mb_strlen($value, 'UTF-8') > $column->getLength()) { + return 'string too long'; + } + break; + case 'blob': + case 'varbinary': + if (base64_decode($value, true) === false) { + return 'invalid base64'; + } + if ($column->hasLength() && strlen(base64_decode($value)) > $column->getLength()) { + return 'string too long'; + } + break; + case 'geometry': + // no checks yet + break; + } + } else { // check non-string types + switch ($column->getType()) { + case 'integer': + case 'bigint': + if (!is_int($value)) { + return 'invalid integer'; + } + break; + case 'float': + case 'double': + if (!is_float($value) && !is_int($value)) { + return 'invalid float'; + } + break; + case 'boolean': + if (!is_bool($value) && ($value !== 0) && ($value !== 1)) { + return 'invalid boolean'; + } + break; + default: + return 'invalid ' . $column->getType(); + } + } + // extra checks + switch ($column->getType()) { + case 'integer': // 4 byte signed + $value = filter_var($value, FILTER_VALIDATE_INT); + if ($value > 2147483647 || $value < -2147483648) { + return 'invalid integer'; + } + break; + } + } + return (true); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableName = RequestUtils::getPathSegment($request, 2); + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as $r) { + $response = $this->callHandler($handler, $r, $operation, $table); + if ($response !== null) { + return $response; + } + } + } else { + $response = $this->callHandler($handler, $record, $operation, $table); + if ($response !== null) { + return $response; + } + } + } + } + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/XmlMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/XmlMiddleware.php new file mode 100644 index 0000000..b613ce1 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/XmlMiddleware.php @@ -0,0 +1,155 @@ +reflection = $reflection; + } + + private function json2xml($json, $types = 'null,boolean,number,string,object,array') + { + $a = json_decode($json); + $d = new \DOMDocument(); + $c = $d->createElement("root"); + $d->appendChild($c); + $t = function ($v) { + $type = gettype($v); + switch ($type) { + case 'integer': + return 'number'; + case 'double': + return 'number'; + default: + return strtolower($type); + } + }; + $ts = explode(',', $types); + $f = function ($f, $c, $a, $s = false) use ($t, $d, $ts) { + if (in_array($t($a), $ts)) { + $c->setAttribute('type', $t($a)); + } + if ($t($a) != 'array' && $t($a) != 'object') { + if ($t($a) == 'boolean') { + $c->appendChild($d->createTextNode($a ? 'true' : 'false')); + } else { + $c->appendChild($d->createTextNode($a)); + } + } else { + foreach ($a as $k => $v) { + if ($k == '__type' && $t($a) == 'object') { + $c->setAttribute('__type', $v); + } else { + if ($t($v) == 'object') { + $ch = $c->appendChild($d->createElementNS(null, $s ? 'item' : $k)); + $f($f, $ch, $v); + } else if ($t($v) == 'array') { + $ch = $c->appendChild($d->createElementNS(null, $s ? 'item' : $k)); + $f($f, $ch, $v, true); + } else { + $va = $d->createElementNS(null, $s ? 'item' : $k); + if ($t($v) == 'boolean') { + $va->appendChild($d->createTextNode($v ? 'true' : 'false')); + } else { + $va->appendChild($d->createTextNode($v)); + } + $ch = $c->appendChild($va); + if (in_array($t($v), $ts)) { + $ch->setAttribute('type', $t($v)); + } + } + } + } + } + }; + $f($f, $c, $a, $t($a) == 'array'); + return $d->saveXML($d->documentElement); + } + + private function xml2json($xml) + { + $a = @dom_import_simplexml(simplexml_load_string($xml)); + if (!$a) { + return null; + } + $t = function ($v) { + $t = $v->getAttribute('type'); + $txt = $v->firstChild->nodeType == XML_TEXT_NODE; + return $t ?: ($txt ? 'string' : 'object'); + }; + $f = function ($f, $a) use ($t) { + $c = null; + if ($t($a) == 'null') { + $c = null; + } else if ($t($a) == 'boolean') { + $b = substr(strtolower($a->textContent), 0, 1); + $c = in_array($b, array('1', 't')); + } else if ($t($a) == 'number') { + $c = $a->textContent + 0; + } else if ($t($a) == 'string') { + $c = $a->textContent; + } else if ($t($a) == 'object') { + $c = array(); + if ($a->getAttribute('__type')) { + $c['__type'] = $a->getAttribute('__type'); + } + for ($i = 0; $i < $a->childNodes->length; $i++) { + $v = $a->childNodes[$i]; + $c[$v->nodeName] = $f($f, $v); + } + $c = (object) $c; + } else if ($t($a) == 'array') { + $c = array(); + for ($i = 0; $i < $a->childNodes->length; $i++) { + $v = $a->childNodes[$i]; + $c[$i] = $f($f, $v); + } + } + return $c; + }; + $c = $f($f, $a); + return json_encode($c); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + parse_str($request->getUri()->getQuery(), $params); + $isXml = isset($params['format']) && $params['format'] == 'xml'; + if ($isXml) { + $body = $request->getBody()->getContents(); + if ($body) { + $json = $this->xml2json($body); + $request = $request->withParsedBody(json_decode($json)); + } + } + $response = $next->handle($request); + if ($isXml) { + $body = $response->getBody()->getContents(); + if ($body) { + $types = implode(',', $this->getArrayProperty('types', 'null,array')); + if ($types == '' || $types == 'all') { + $xml = $this->json2xml($body); + } else { + $xml = $this->json2xml($body, $types); + } + $response = ResponseFactory::fromXml(ResponseFactory::OK, $xml); + } + } + return $response; + } +} diff --git a/src/Tqdev/PhpCrudApi/Middleware/XsrfMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/XsrfMiddleware.php new file mode 100644 index 0000000..6529b25 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/XsrfMiddleware.php @@ -0,0 +1,42 @@ +getProperty('cookieName', 'XSRF-TOKEN'); + if (isset($_COOKIE[$cookieName])) { + $token = $_COOKIE[$cookieName]; + } else { + $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on'; + $token = bin2hex(random_bytes(8)); + if (!headers_sent()) { + setcookie($cookieName, $token, 0, '', '', $secure); + } + } + return $token; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $token = $this->getToken(); + $method = $request->getMethod(); + $excludeMethods = $this->getArrayProperty('excludeMethods', 'OPTIONS,GET'); + if (!in_array($method, $excludeMethods)) { + $headerName = $this->getProperty('headerName', 'X-XSRF-TOKEN'); + if ($token != $request->getHeader($headerName)) { + return $this->responder->error(ErrorCode::BAD_OR_MISSING_XSRF_TOKEN, ''); + } + } + return $next->handle($request); + } +} diff --git a/src/Tqdev/PhpCrudApi/OpenApi/OpenApiBuilder.php b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiBuilder.php new file mode 100644 index 0000000..f022d57 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiBuilder.php @@ -0,0 +1,53 @@ +openapi = new OpenApiDefinition($base); + $this->records = in_array('records', $controllers) ? new OpenApiRecordsBuilder($this->openapi, $reflection) : null; + $this->columns = in_array('columns', $controllers) ? new OpenApiColumnsBuilder($this->openapi) : null; + $this->builders = array(); + foreach ($builders as $className) { + $this->builders[] = new $className($this->openapi, $reflection); + } + } + + private function getServerUrl(): string + { + $protocol = @$_SERVER['HTTP_X_FORWARDED_PROTO'] ?: @$_SERVER['REQUEST_SCHEME'] ?: ((isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] == "on") ? "https" : "http"); + $port = @intval($_SERVER['HTTP_X_FORWARDED_PORT']) ?: @intval($_SERVER["SERVER_PORT"]) ?: (($protocol === 'https') ? 443 : 80); + $host = @explode(":", $_SERVER['HTTP_HOST'])[0] ?: @$_SERVER['SERVER_NAME'] ?: @$_SERVER['SERVER_ADDR']; + $port = ($protocol === 'https' && $port === 443) || ($protocol === 'http' && $port === 80) ? '' : ':' . $port; + $path = @trim(substr($_SERVER['REQUEST_URI'], 0, strpos($_SERVER['REQUEST_URI'], '/openapi')), '/'); + return sprintf('%s://%s%s/%s', $protocol, $host, $port, $path); + } + + public function build(): OpenApiDefinition + { + $this->openapi->set("openapi", "3.0.0"); + if (!$this->openapi->has("servers") && isset($_SERVER['REQUEST_URI'])) { + $this->openapi->set("servers|0|url", $this->getServerUrl()); + } + if ($this->records) { + $this->records->build(); + } + if ($this->columns) { + $this->columns->build(); + } + foreach ($this->builders as $builder) { + $builder->build(); + } + return $this->openapi; + } +} diff --git a/src/Tqdev/PhpCrudApi/OpenApi/OpenApiColumnsBuilder.php b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiColumnsBuilder.php new file mode 100644 index 0000000..79a2f60 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiColumnsBuilder.php @@ -0,0 +1,193 @@ + [ + 'read' => 'get', + ], + 'table' => [ + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', //rename + 'delete' => 'delete', + ], + 'column' => [ + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', + 'delete' => 'delete', + ] + ]; + + public function __construct(OpenApiDefinition $openapi) + { + $this->openapi = $openapi; + } + + public function build() /*: void*/ + { + $this->setPaths(); + $this->openapi->set("components|responses|boolSuccess|description", "boolean indicating success or failure"); + $this->openapi->set("components|responses|boolSuccess|content|application/json|schema|type", "boolean"); + $this->setComponentSchema(); + $this->setComponentResponse(); + $this->setComponentRequestBody(); + $this->setComponentParameters(); + foreach (array_keys($this->operations) as $index => $type) { + $this->setTag($index, $type); + } + } + + private function setPaths() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach ($this->operations[$type] as $operation => $method) { + $parameters = []; + switch ($type) { + case 'database': + $path = '/columns'; + break; + case 'table': + $path = $operation == 'create' ? '/columns' : '/columns/{table}'; + break; + case 'column': + $path = $operation == 'create' ? '/columns/{table}' : '/columns/{table}/{column}'; + break; + } + if (strpos($path, '{table}')) { + $parameters[] = 'table'; + } + if (strpos($path, '{column}')) { + $parameters[] = 'column'; + } + foreach ($parameters as $p => $parameter) { + $this->openapi->set("paths|$path|$method|parameters|$p|\$ref", "#/components/parameters/$parameter"); + } + $operationType = $operation . ucfirst($type); + if (in_array($operation, ['create', 'update'])) { + $this->openapi->set("paths|$path|$method|requestBody|\$ref", "#/components/requestBodies/$operationType"); + } + $this->openapi->set("paths|$path|$method|tags|0", "$type"); + $this->openapi->set("paths|$path|$method|operationId", "$operation" . "_" . "$type"); + if ($operationType == 'updateTable') { + $this->openapi->set("paths|$path|$method|description", "rename table"); + } else { + $this->openapi->set("paths|$path|$method|description", "$operation $type"); + } + switch ($operation) { + case 'read': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operationType"); + break; + case 'create': + case 'update': + case 'delete': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/boolSuccess"); + break; + } + } + } + } + + private function setComponentSchema() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if ($operation == 'delete') { + continue; + } + $operationType = $operation . ucfirst($type); + $prefix = "components|schemas|$operationType"; + $this->openapi->set("$prefix|type", "object"); + switch ($type) { + case 'database': + $this->openapi->set("$prefix|properties|tables|type", 'array'); + $this->openapi->set("$prefix|properties|tables|items|\$ref", "#/components/schemas/readTable"); + break; + case 'table': + if ($operation == 'update') { + $this->openapi->set("$prefix|required", ['name']); + $this->openapi->set("$prefix|properties|name|type", 'string'); + } else { + $this->openapi->set("$prefix|properties|name|type", 'string'); + if ($operation == 'read') { + $this->openapi->set("$prefix|properties|type|type", 'string'); + } + $this->openapi->set("$prefix|properties|columns|type", 'array'); + $this->openapi->set("$prefix|properties|columns|items|\$ref", "#/components/schemas/readColumn"); + } + break; + case 'column': + $this->openapi->set("$prefix|required", ['name', 'type']); + $this->openapi->set("$prefix|properties|name|type", 'string'); + $this->openapi->set("$prefix|properties|type|type", 'string'); + $this->openapi->set("$prefix|properties|length|type", 'integer'); + $this->openapi->set("$prefix|properties|length|format", "int64"); + $this->openapi->set("$prefix|properties|precision|type", 'integer'); + $this->openapi->set("$prefix|properties|precision|format", "int64"); + $this->openapi->set("$prefix|properties|scale|type", 'integer'); + $this->openapi->set("$prefix|properties|scale|format", "int64"); + $this->openapi->set("$prefix|properties|nullable|type", 'boolean'); + $this->openapi->set("$prefix|properties|pk|type", 'boolean'); + $this->openapi->set("$prefix|properties|fk|type", 'string'); + break; + } + } + } + } + + private function setComponentResponse() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if ($operation != 'read') { + continue; + } + $operationType = $operation . ucfirst($type); + $this->openapi->set("components|responses|$operationType|description", "single $type record"); + $this->openapi->set("components|responses|$operationType|content|application/json|schema|\$ref", "#/components/schemas/$operationType"); + } + } + } + + private function setComponentRequestBody() /*: void*/ + { + foreach (array_keys($this->operations) as $type) { + foreach (array_keys($this->operations[$type]) as $operation) { + if (!in_array($operation, ['create', 'update'])) { + continue; + } + $operationType = $operation . ucfirst($type); + $this->openapi->set("components|requestBodies|$operationType|description", "single $type record"); + $this->openapi->set("components|requestBodies|$operationType|content|application/json|schema|\$ref", "#/components/schemas/$operationType"); + } + } + } + + + private function setComponentParameters() /*: void*/ + { + $this->openapi->set("components|parameters|table|name", "table"); + $this->openapi->set("components|parameters|table|in", "path"); + $this->openapi->set("components|parameters|table|schema|type", "string"); + $this->openapi->set("components|parameters|table|description", "table name"); + $this->openapi->set("components|parameters|table|required", true); + + $this->openapi->set("components|parameters|column|name", "column"); + $this->openapi->set("components|parameters|column|in", "path"); + $this->openapi->set("components|parameters|column|schema|type", "string"); + $this->openapi->set("components|parameters|column|description", "column name"); + $this->openapi->set("components|parameters|column|required", true); + } + + private function setTag(int $index, string $type) /*: void*/ + { + $this->openapi->set("tags|$index|name", "$type"); + $this->openapi->set("tags|$index|description", "$type operations"); + } +} diff --git a/src/Tqdev/PhpCrudApi/OpenApi/OpenApiDefinition.php b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiDefinition.php new file mode 100644 index 0000000..9e01224 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiDefinition.php @@ -0,0 +1,46 @@ +root = $base; + } + + public function set(string $path, $value) /*: void*/ + { + $parts = explode('|', trim($path, '|')); + $current = &$this->root; + while (count($parts) > 0) { + $part = array_shift($parts); + if (!isset($current[$part])) { + $current[$part] = []; + } + $current = &$current[$part]; + } + $current = $value; + } + + public function has(string $path): bool + { + $parts = explode('|', trim($path, '|')); + $current = &$this->root; + while (count($parts) > 0) { + $part = array_shift($parts); + if (!isset($current[$part])) { + return false; + } + $current = &$current[$part]; + } + return true; + } + + public function jsonSerialize() + { + return $this->root; + } +} diff --git a/src/Tqdev/PhpCrudApi/OpenApi/OpenApiRecordsBuilder.php b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiRecordsBuilder.php new file mode 100644 index 0000000..e80ac6c --- /dev/null +++ b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiRecordsBuilder.php @@ -0,0 +1,374 @@ + 'get', + 'create' => 'post', + 'read' => 'get', + 'update' => 'put', + 'delete' => 'delete', + 'increment' => 'patch', + ]; + private $types = [ + 'integer' => ['type' => 'integer', 'format' => 'int32'], + 'bigint' => ['type' => 'integer', 'format' => 'int64'], + 'varchar' => ['type' => 'string'], + 'clob' => ['type' => 'string', 'format' => 'large-string'], //custom format + 'varbinary' => ['type' => 'string', 'format' => 'byte'], + 'blob' => ['type' => 'string', 'format' => 'large-byte'], //custom format + 'decimal' => ['type' => 'string', 'format' => 'decimal'], //custom format + 'float' => ['type' => 'number', 'format' => 'float'], + 'double' => ['type' => 'number', 'format' => 'double'], + 'date' => ['type' => 'string', 'format' => 'date'], + 'time' => ['type' => 'string', 'format' => 'time'], //custom format + 'timestamp' => ['type' => 'string', 'format' => 'date-time'], + 'geometry' => ['type' => 'string', 'format' => 'geometry'], //custom format + 'boolean' => ['type' => 'boolean'], + ]; + + public function __construct(OpenApiDefinition $openapi, ReflectionService $reflection) + { + $this->openapi = $openapi; + $this->reflection = $reflection; + } + + private function getAllTableReferences(): array + { + $tableReferences = array(); + foreach ($this->reflection->getTableNames() as $tableName) { + $table = $this->reflection->getTable($tableName); + foreach ($table->getColumnNames() as $columnName) { + $column = $table->getColumn($columnName); + $referencedTableName = $column->getFk(); + if ($referencedTableName) { + if (!isset($tableReferences[$referencedTableName])) { + $tableReferences[$referencedTableName] = array(); + } + $tableReferences[$referencedTableName][] = "$tableName.$columnName"; + } + } + } + return $tableReferences; + } + + public function build() /*: void*/ + { + $tableNames = $this->reflection->getTableNames(); + foreach ($tableNames as $tableName) { + $this->setPath($tableName); + } + $this->openapi->set("components|responses|pk_integer|description", "inserted primary key value (integer)"); + $this->openapi->set("components|responses|pk_integer|content|application/json|schema|type", "integer"); + $this->openapi->set("components|responses|pk_integer|content|application/json|schema|format", "int64"); + $this->openapi->set("components|responses|pk_string|description", "inserted primary key value (string)"); + $this->openapi->set("components|responses|pk_string|content|application/json|schema|type", "string"); + $this->openapi->set("components|responses|pk_string|content|application/json|schema|format", "uuid"); + $this->openapi->set("components|responses|rows_affected|description", "number of rows affected (integer)"); + $this->openapi->set("components|responses|rows_affected|content|application/json|schema|type", "integer"); + $this->openapi->set("components|responses|rows_affected|content|application/json|schema|format", "int64"); + $tableReferences = $this->getAllTableReferences(); + foreach ($tableNames as $tableName) { + $references = isset($tableReferences[$tableName]) ? $tableReferences[$tableName] : array(); + $this->setComponentSchema($tableName, $references); + $this->setComponentResponse($tableName); + $this->setComponentRequestBody($tableName); + } + $this->setComponentParameters(); + foreach ($tableNames as $index => $tableName) { + $this->setTag($index, $tableName); + } + } + + private function isOperationOnTableAllowed(string $operation, string $tableName): bool + { + $tableHandler = VariableStore::get('authorization.tableHandler'); + if (!$tableHandler) { + return true; + } + return (bool) call_user_func($tableHandler, $operation, $tableName); + } + + private function isOperationOnColumnAllowed(string $operation, string $tableName, string $columnName): bool + { + $columnHandler = VariableStore::get('authorization.columnHandler'); + if (!$columnHandler) { + return true; + } + return (bool) call_user_func($columnHandler, $operation, $tableName, $columnName); + } + + private function setPath(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach ($this->operations as $operation => $method) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type != 'table' && $operation != 'list') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + $parameters = []; + if (in_array($operation, ['list', 'create'])) { + $path = sprintf('/records/%s', $tableName); + if ($operation == 'list') { + $parameters = ['filter', 'include', 'exclude', 'order', 'size', 'page', 'join']; + } + } else { + $path = sprintf('/records/%s/{id}', $tableName); + if ($operation == 'read') { + $parameters = ['pk', 'include', 'exclude', 'join']; + } else { + $parameters = ['pk']; + } + } + foreach ($parameters as $p => $parameter) { + $this->openapi->set("paths|$path|$method|parameters|$p|\$ref", "#/components/parameters/$parameter"); + } + if (in_array($operation, ['create', 'update', 'increment'])) { + $this->openapi->set("paths|$path|$method|requestBody|\$ref", "#/components/requestBodies/$operation-" . rawurlencode($tableName)); + } + $this->openapi->set("paths|$path|$method|tags|0", "$tableName"); + $this->openapi->set("paths|$path|$method|operationId", "$operation" . "_" . "$tableName"); + $this->openapi->set("paths|$path|$method|description", "$operation $tableName"); + switch ($operation) { + case 'list': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operation-" . rawurlencode($tableName)); + break; + case 'create': + if ($pk->getType() == 'integer') { + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/pk_integer"); + } else { + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/pk_string"); + } + break; + case 'read': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/$operation-" . rawurlencode($tableName)); + break; + case 'update': + case 'delete': + case 'increment': + $this->openapi->set("paths|$path|$method|responses|200|\$ref", "#/components/responses/rows_affected"); + break; + } + } + } + + private function getPattern(ReflectedColumn $column): string + { + switch ($column->getType()) { + case 'integer': + $n = strlen(pow(2, 31)); + return '^-?[0-9]{1,' . $n . '}$'; + case 'bigint': + $n = strlen(pow(2, 63)); + return '^-?[0-9]{1,' . $n . '}$'; + case 'varchar': + $l = $column->getLength(); + return '^.{0,' . $l . '}$'; + case 'clob': + return '^.*$'; + case 'varbinary': + $l = $column->getLength(); + $b = (int) 4 * ceil($l / 3); + return '^[A-Za-z0-9+/]{0,' . $b . '}=*$'; + case 'blob': + return '^[A-Za-z0-9+/]*=*$'; + case 'decimal': + $p = $column->getPrecision(); + $s = $column->getScale(); + return '^-?[0-9]{1,' . ($p - $s) . '}(\.[0-9]{1,' . $s . '})?$'; + case 'float': + return '^-?[0-9]+(\.[0-9]+)?([eE]-?[0-9]+)?$'; + case 'double': + return '^-?[0-9]+(\.[0-9]+)?([eE]-?[0-9]+)?$'; + case 'date': + return '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; + case 'time': + return '^[0-9]{2}:[0-9]{2}:[0-9]{2}$'; + case 'timestamp': + return '^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$'; + return ''; + case 'geometry': + return '^(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON)\s*\(.*$'; + case 'boolean': + return '^(true|false)$'; + } + return ''; + } + + private function setComponentSchema(string $tableName, array $references) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach ($this->operations as $operation => $method) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type == 'view' && !in_array($operation, array('read', 'list'))) { + continue; + } + if ($type == 'view' && !$pkName && $operation == 'read') { + continue; + } + if ($operation == 'delete') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + if ($operation == 'list') { + $this->openapi->set("components|schemas|$operation-$tableName|type", "object"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|results|type", "integer"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|results|format", "int64"); + $this->openapi->set("components|schemas|$operation-$tableName|properties|records|type", "array"); + $prefix = "components|schemas|$operation-$tableName|properties|records|items"; + } else { + $prefix = "components|schemas|$operation-$tableName"; + } + $this->openapi->set("$prefix|type", "object"); + foreach ($table->getColumnNames() as $columnName) { + if (!$this->isOperationOnColumnAllowed($operation, $tableName, $columnName)) { + continue; + } + $column = $table->getColumn($columnName); + $properties = $this->types[$column->getType()]; + $properties['maxLength'] = $column->hasLength() ? $column->getLength() : 0; + $properties['nullable'] = $column->getNullable(); + $properties['pattern'] = $this->getPattern($column); + foreach ($properties as $key => $value) { + if ($value) { + $this->openapi->set("$prefix|properties|$columnName|$key", $value); + } + } + if ($column->getPk()) { + $this->openapi->set("$prefix|properties|$columnName|x-primary-key", true); + $this->openapi->set("$prefix|properties|$columnName|x-referenced", $references); + } + $fk = $column->getFk(); + if ($fk) { + $this->openapi->set("$prefix|properties|$columnName|x-references", $fk); + } + } + } + } + + private function setComponentResponse(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + foreach (['list', 'read'] as $operation) { + if (!$pkName && $operation != 'list') { + continue; + } + if ($type != 'table' && $operation != 'list') { + continue; + } + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + if ($operation == 'list') { + $this->openapi->set("components|responses|$operation-$tableName|description", "list of $tableName records"); + } else { + $this->openapi->set("components|responses|$operation-$tableName|description", "single $tableName record"); + } + $this->openapi->set("components|responses|$operation-$tableName|content|application/json|schema|\$ref", "#/components/schemas/$operation-" . rawurlencode($tableName)); + } + } + + private function setComponentRequestBody(string $tableName) /*: void*/ + { + $table = $this->reflection->getTable($tableName); + $type = $table->getType(); + $pk = $table->getPk(); + $pkName = $pk ? $pk->getName() : ''; + if ($pkName && $type == 'table') { + foreach (['create', 'update', 'increment'] as $operation) { + if (!$this->isOperationOnTableAllowed($operation, $tableName)) { + continue; + } + $this->openapi->set("components|requestBodies|$operation-$tableName|description", "single $tableName record"); + $this->openapi->set("components|requestBodies|$operation-$tableName|content|application/json|schema|\$ref", "#/components/schemas/$operation-" . rawurlencode($tableName)); + } + } + } + + private function setComponentParameters() /*: void*/ + { + $this->openapi->set("components|parameters|pk|name", "id"); + $this->openapi->set("components|parameters|pk|in", "path"); + $this->openapi->set("components|parameters|pk|schema|type", "string"); + $this->openapi->set("components|parameters|pk|description", "primary key value"); + $this->openapi->set("components|parameters|pk|required", true); + + $this->openapi->set("components|parameters|filter|name", "filter"); + $this->openapi->set("components|parameters|filter|in", "query"); + $this->openapi->set("components|parameters|filter|schema|type", "array"); + $this->openapi->set("components|parameters|filter|schema|items|type", "string"); + $this->openapi->set("components|parameters|filter|description", "Filters to be applied. Each filter consists of a column, an operator and a value (comma separated). Example: id,eq,1"); + $this->openapi->set("components|parameters|filter|required", false); + + $this->openapi->set("components|parameters|include|name", "include"); + $this->openapi->set("components|parameters|include|in", "query"); + $this->openapi->set("components|parameters|include|schema|type", "string"); + $this->openapi->set("components|parameters|include|description", "Columns you want to include in the output (comma separated). Example: posts.*,categories.name"); + $this->openapi->set("components|parameters|include|required", false); + + $this->openapi->set("components|parameters|exclude|name", "exclude"); + $this->openapi->set("components|parameters|exclude|in", "query"); + $this->openapi->set("components|parameters|exclude|schema|type", "string"); + $this->openapi->set("components|parameters|exclude|description", "Columns you want to exclude from the output (comma separated). Example: posts.content"); + $this->openapi->set("components|parameters|exclude|required", false); + + $this->openapi->set("components|parameters|order|name", "order"); + $this->openapi->set("components|parameters|order|in", "query"); + $this->openapi->set("components|parameters|order|schema|type", "array"); + $this->openapi->set("components|parameters|order|schema|items|type", "string"); + $this->openapi->set("components|parameters|order|description", "Column you want to sort on and the sort direction (comma separated). Example: id,desc"); + $this->openapi->set("components|parameters|order|required", false); + + $this->openapi->set("components|parameters|size|name", "size"); + $this->openapi->set("components|parameters|size|in", "query"); + $this->openapi->set("components|parameters|size|schema|type", "string"); + $this->openapi->set("components|parameters|size|description", "Maximum number of results (for top lists). Example: 10"); + $this->openapi->set("components|parameters|size|required", false); + + $this->openapi->set("components|parameters|page|name", "page"); + $this->openapi->set("components|parameters|page|in", "query"); + $this->openapi->set("components|parameters|page|schema|type", "string"); + $this->openapi->set("components|parameters|page|description", "Page number and page size (comma separated). Example: 1,10"); + $this->openapi->set("components|parameters|page|required", false); + + $this->openapi->set("components|parameters|join|name", "join"); + $this->openapi->set("components|parameters|join|in", "query"); + $this->openapi->set("components|parameters|join|schema|type", "array"); + $this->openapi->set("components|parameters|join|schema|items|type", "string"); + $this->openapi->set("components|parameters|join|description", "Paths (comma separated) to related entities that you want to include. Example: comments,users"); + $this->openapi->set("components|parameters|join|required", false); + } + + private function setTag(int $index, string $tableName) /*: void*/ + { + $this->openapi->set("tags|$index|name", "$tableName"); + $this->openapi->set("tags|$index|description", "$tableName operations"); + } +} diff --git a/src/Tqdev/PhpCrudApi/OpenApi/OpenApiService.php b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiService.php new file mode 100644 index 0000000..bf83f64 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/OpenApi/OpenApiService.php @@ -0,0 +1,21 @@ +builder = new OpenApiBuilder($reflection, $base, $controllers, $customBuilders); + } + + public function get(): OpenApiDefinition + { + return $this->builder->build(); + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/ColumnIncluder.php b/src/Tqdev/PhpCrudApi/Record/ColumnIncluder.php new file mode 100644 index 0000000..4ec42fb --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/ColumnIncluder.php @@ -0,0 +1,71 @@ +isMandatory($tableName, $columnName, $params)) { + $result[] = $columnName; + } + } else { + if (!$include || $this->isMandatory($tableName, $columnName, $params)) { + $result[] = $columnName; + } + } + } + return $result; + } + + public function getNames(ReflectedTable $table, bool $primaryTable, array $params): array + { + $tableName = $table->getName(); + $results = $table->getColumnNames(); + $results = $this->select($tableName, $primaryTable, $params, 'include', $results, true); + $results = $this->select($tableName, $primaryTable, $params, 'exclude', $results, false); + return $results; + } + + public function getValues(ReflectedTable $table, bool $primaryTable, /* object */ $record, array $params): array + { + $results = array(); + $columnNames = $this->getNames($table, $primaryTable, $params); + foreach ($columnNames as $columnName) { + if (property_exists($record, $columnName)) { + $results[$columnName] = $record->$columnName; + } + } + return $results; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/Condition/AndCondition.php b/src/Tqdev/PhpCrudApi/Record/Condition/AndCondition.php new file mode 100644 index 0000000..a8d9f3f --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/Condition/AndCondition.php @@ -0,0 +1,36 @@ +conditions = [$condition1, $condition2]; + } + + public function _and(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + $this->conditions[] = $condition; + return $this; + } + + public function getConditions(): array + { + return $this->conditions; + } + + public static function fromArray(array $conditions): Condition + { + $condition = new NoCondition(); + foreach ($conditions as $c) { + $condition = $condition->_and($c); + } + return $condition; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/Condition/ColumnCondition.php b/src/Tqdev/PhpCrudApi/Record/Condition/ColumnCondition.php new file mode 100644 index 0000000..32254fb --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/Condition/ColumnCondition.php @@ -0,0 +1,34 @@ +column = $column; + $this->operator = $operator; + $this->value = $value; + } + + public function getColumn(): ReflectedColumn + { + return $this->column; + } + + public function getOperator(): string + { + return $this->operator; + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/Condition/Condition.php b/src/Tqdev/PhpCrudApi/Record/Condition/Condition.php new file mode 100644 index 0000000..021de6c --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/Condition/Condition.php @@ -0,0 +1,68 @@ +getColumn($parts[0]); + $command = $parts[1]; + $negate = false; + $spatial = false; + if (strlen($command) > 2) { + if (substr($command, 0, 1) == 'n') { + $negate = true; + $command = substr($command, 1); + } + if (substr($command, 0, 1) == 's') { + $spatial = true; + $command = substr($command, 1); + } + } + if ($spatial) { + if (in_array($command, ['co', 'cr', 'di', 'eq', 'in', 'ov', 'to', 'wi', 'ic', 'is', 'iv'])) { + $condition = new SpatialCondition($field, $command, $parts[2]); + } + } else { + if (in_array($command, ['cs', 'sw', 'ew', 'eq', 'lt', 'le', 'ge', 'gt', 'bt', 'in', 'is'])) { + $condition = new ColumnCondition($field, $command, $parts[2]); + } + } + if ($negate) { + $condition = $condition->_not(); + } + return $condition; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/Condition/NoCondition.php b/src/Tqdev/PhpCrudApi/Record/Condition/NoCondition.php new file mode 100644 index 0000000..e994aaa --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/Condition/NoCondition.php @@ -0,0 +1,21 @@ +condition = $condition; + } + + public function getCondition(): Condition + { + return $this->condition; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/Condition/OrCondition.php b/src/Tqdev/PhpCrudApi/Record/Condition/OrCondition.php new file mode 100644 index 0000000..388c944 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/Condition/OrCondition.php @@ -0,0 +1,36 @@ +conditions = [$condition1, $condition2]; + } + + public function _or(Condition $condition): Condition + { + if ($condition instanceof NoCondition) { + return $this; + } + $this->conditions[] = $condition; + return $this; + } + + public function getConditions(): array + { + return $this->conditions; + } + + public static function fromArray(array $conditions): Condition + { + $condition = new NoCondition(); + foreach ($conditions as $c) { + $condition = $condition->_or($c); + } + return $condition; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/Condition/SpatialCondition.php b/src/Tqdev/PhpCrudApi/Record/Condition/SpatialCondition.php new file mode 100644 index 0000000..cbd8903 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/Condition/SpatialCondition.php @@ -0,0 +1,7 @@ +code = $errorCode->getCode(); + $this->message = $errorCode->getMessage($argument); + $this->details = $details; + } + + public function getCode(): int + { + return $this->code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function serialize() + { + return [ + 'code' => $this->code, + 'message' => $this->message, + 'details' => $this->details, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize()); + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/Document/ListDocument.php b/src/Tqdev/PhpCrudApi/Record/Document/ListDocument.php new file mode 100644 index 0000000..e751464 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/Document/ListDocument.php @@ -0,0 +1,41 @@ +records = $records; + $this->results = $results; + } + + public function getRecords(): array + { + return $this->records; + } + + public function getResults(): int + { + return $this->results; + } + + public function serialize() + { + return [ + 'records' => $this->records, + 'results' => $this->results, + ]; + } + + public function jsonSerialize() + { + return array_filter($this->serialize(), function ($v) { + return $v !== 0; + }); + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/ErrorCode.php b/src/Tqdev/PhpCrudApi/Record/ErrorCode.php new file mode 100644 index 0000000..580d56d --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/ErrorCode.php @@ -0,0 +1,87 @@ + ["%s", ResponseFactory::INTERNAL_SERVER_ERROR], + 1000 => ["Route '%s' not found", ResponseFactory::NOT_FOUND], + 1001 => ["Table '%s' not found", ResponseFactory::NOT_FOUND], + 1002 => ["Argument count mismatch in '%s'", ResponseFactory::UNPROCESSABLE_ENTITY], + 1003 => ["Record '%s' not found", ResponseFactory::NOT_FOUND], + 1004 => ["Origin '%s' is forbidden", ResponseFactory::FORBIDDEN], + 1005 => ["Column '%s' not found", ResponseFactory::NOT_FOUND], + 1006 => ["Table '%s' already exists", ResponseFactory::CONFLICT], + 1007 => ["Column '%s' already exists", ResponseFactory::CONFLICT], + 1008 => ["Cannot read HTTP message", ResponseFactory::UNPROCESSABLE_ENTITY], + 1009 => ["Duplicate key exception", ResponseFactory::CONFLICT], + 1010 => ["Data integrity violation", ResponseFactory::CONFLICT], + 1011 => ["Authentication required", ResponseFactory::UNAUTHORIZED], + 1012 => ["Authentication failed for '%s'", ResponseFactory::FORBIDDEN], + 1013 => ["Input validation failed for '%s'", ResponseFactory::UNPROCESSABLE_ENTITY], + 1014 => ["Operation forbidden", ResponseFactory::FORBIDDEN], + 1015 => ["Operation '%s' not supported", ResponseFactory::METHOD_NOT_ALLOWED], + 1016 => ["Temporary or permanently blocked", ResponseFactory::FORBIDDEN], + 1017 => ["Bad or missing XSRF token", ResponseFactory::FORBIDDEN], + 1018 => ["Only AJAX requests allowed for '%s'", ResponseFactory::FORBIDDEN], + 1019 => ["Pagination forbidden", ResponseFactory::FORBIDDEN], + 1020 => ["User '%s' already exists", ResponseFactory::CONFLICT], + 1021 => ["Password too short (<%d characters)", ResponseFactory::UNPROCESSABLE_ENTITY], + ]; + + public function __construct(int $code) + { + if (!isset($this->values[$code])) { + $code = 9999; + } + $this->code = $code; + $this->message = $this->values[$code][0]; + $this->status = $this->values[$code][1]; + } + + public function getCode(): int + { + return $this->code; + } + + public function getMessage(string $argument): string + { + return sprintf($this->message, $argument); + } + + public function getStatus(): int + { + return $this->status; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/FilterInfo.php b/src/Tqdev/PhpCrudApi/Record/FilterInfo.php new file mode 100644 index 0000000..b072da1 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/FilterInfo.php @@ -0,0 +1,47 @@ + $filters) { + if (substr($key, 0, 6) == 'filter') { + preg_match_all('/\d+|\D+/', substr($key, 6), $matches); + $path = $matches[0]; + foreach ($filters as $filter) { + $condition = Condition::fromString($table, $filter); + if (($condition instanceof NoCondition) == false) { + $conditions->put($path, $condition); + } + } + } + } + return $conditions; + } + + private function combinePathTreeOfConditions(PathTree $tree): Condition + { + $andConditions = $tree->getValues(); + $and = AndCondition::fromArray($andConditions); + $orConditions = []; + foreach ($tree->getKeys() as $p) { + $orConditions[] = $this->combinePathTreeOfConditions($tree->get($p)); + } + $or = OrCondition::fromArray($orConditions); + return $and->_and($or); + } + + public function getCombinedConditions(ReflectedTable $table, array $params): Condition + { + return $this->combinePathTreeOfConditions($this->getConditionsAsPathTree($table, $params)); + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/HabtmValues.php b/src/Tqdev/PhpCrudApi/Record/HabtmValues.php new file mode 100644 index 0000000..42ced36 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/HabtmValues.php @@ -0,0 +1,15 @@ +pkValues = $pkValues; + $this->fkValues = $fkValues; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/OrderingInfo.php b/src/Tqdev/PhpCrudApi/Record/OrderingInfo.php new file mode 100644 index 0000000..201405a --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/OrderingInfo.php @@ -0,0 +1,47 @@ +hasColumn($columnName)) { + continue; + } + $ascending = 'ASC'; + if (count($parts) > 1) { + if (substr(strtoupper($parts[1]), 0, 4) == "DESC") { + $ascending = 'DESC'; + } + } + $fields[] = [$columnName, $ascending]; + } + } + if (count($fields) == 0) { + return $this->getDefaultColumnOrdering($table); + } + return $fields; + } + + public function getDefaultColumnOrdering(ReflectedTable $table): array + { + $fields = array(); + $pk = $table->getPk(); + if ($pk) { + $fields[] = [$pk->getName(), 'ASC']; + } else { + foreach ($table->getColumnNames() as $columnName) { + $fields[] = [$columnName, 'ASC']; + } + } + return $fields; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/PaginationInfo.php b/src/Tqdev/PhpCrudApi/Record/PaginationInfo.php new file mode 100644 index 0000000..d8aa7f1 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/PaginationInfo.php @@ -0,0 +1,69 @@ +getPageSize($params); + if (isset($params['page'])) { + foreach ($params['page'] as $page) { + $parts = explode(',', $page, 2); + $page = intval($parts[0]) - 1; + $offset = $page * $pageSize; + } + } + return $offset; + } + + private function getPageSize(array $params): int + { + $pageSize = $this->DEFAULT_PAGE_SIZE; + if (isset($params['page'])) { + foreach ($params['page'] as $page) { + $parts = explode(',', $page, 2); + if (count($parts) > 1) { + $pageSize = intval($parts[1]); + } + } + } + return $pageSize; + } + + public function getResultSize(array $params): int + { + $numberOfRows = -1; + if (isset($params['size'])) { + foreach ($params['size'] as $size) { + $numberOfRows = intval($size); + } + } + return $numberOfRows; + } + + public function getPageLimit(array $params): int + { + $pageLimit = -1; + if ($this->hasPage($params)) { + $pageLimit = $this->getPageSize($params); + } + $resultSize = $this->getResultSize($params); + if ($resultSize >= 0) { + if ($pageLimit >= 0) { + $pageLimit = min($pageLimit, $resultSize); + } else { + $pageLimit = $resultSize; + } + } + return $pageLimit; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/PathTree.php b/src/Tqdev/PhpCrudApi/Record/PathTree.php new file mode 100644 index 0000000..cebf329 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/PathTree.php @@ -0,0 +1,80 @@ +newTree(); + } + $this->tree = &$tree; + } + + public function newTree() + { + return (object) ['values' => [], 'branches' => (object) []]; + } + + public function getKeys(): array + { + $branches = (array) $this->tree->branches; + return array_keys($branches); + } + + public function getValues(): array + { + return $this->tree->values; + } + + public function get(string $key): PathTree + { + if (!isset($this->tree->branches->$key)) { + return null; + } + return new PathTree($this->tree->branches->$key); + } + + public function put(array $path, $value) + { + $tree = &$this->tree; + foreach ($path as $key) { + if (!isset($tree->branches->$key)) { + $tree->branches->$key = $this->newTree(); + } + $tree = &$tree->branches->$key; + } + $tree->values[] = $value; + } + + public function match(array $path): array + { + $star = self::WILDCARD; + $tree = &$this->tree; + foreach ($path as $key) { + if (isset($tree->branches->$key)) { + $tree = &$tree->branches->$key; + } elseif (isset($tree->branches->$star)) { + $tree = &$tree->branches->$star; + } else { + return []; + } + } + return $tree->values; + } + + public static function fromJson(/* object */$tree): PathTree + { + return new PathTree($tree); + } + + public function jsonSerialize() + { + return $this->tree; + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/RecordService.php b/src/Tqdev/PhpCrudApi/Record/RecordService.php new file mode 100644 index 0000000..b31ab13 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/RecordService.php @@ -0,0 +1,123 @@ +db = $db; + $this->reflection = $reflection; + $this->columns = new ColumnIncluder(); + $this->joiner = new RelationJoiner($reflection, $this->columns); + $this->filters = new FilterInfo(); + $this->ordering = new OrderingInfo(); + $this->pagination = new PaginationInfo(); + } + + private function sanitizeRecord(string $tableName, /* object */ $record, string $id) + { + $keyset = array_keys((array) $record); + foreach ($keyset as $key) { + if (!$this->reflection->getTable($tableName)->hasColumn($key)) { + unset($record->$key); + } + } + if ($id != '') { + $pk = $this->reflection->getTable($tableName)->getPk(); + foreach ($this->reflection->getTable($tableName)->getColumnNames() as $key) { + $field = $this->reflection->getTable($tableName)->getColumn($key); + if ($field->getName() == $pk->getName()) { + unset($record->$key); + } + } + } + } + + public function hasTable(string $table): bool + { + return $this->reflection->hasTable($table); + } + + public function getType(string $table): string + { + return $this->reflection->getType($table); + } + + public function create(string $tableName, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, ''); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->createSingle($table, $columnValues); + } + + public function read(string $tableName, string $id, array $params) /*: ?object*/ + { + $table = $this->reflection->getTable($tableName); + $this->joiner->addMandatoryColumns($table, $params); + $columnNames = $this->columns->getNames($table, true, $params); + $record = $this->db->selectSingle($table, $columnNames, $id); + if ($record == null) { + return null; + } + $records = array($record); + $this->joiner->addJoins($table, $records, $params, $this->db); + return $records[0]; + } + + public function update(string $tableName, string $id, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, $id); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->updateSingle($table, $columnValues, $id); + } + + public function delete(string $tableName, string $id, array $params) /*: ?int*/ + { + $table = $this->reflection->getTable($tableName); + return $this->db->deleteSingle($table, $id); + } + + public function increment(string $tableName, string $id, /* object */ $record, array $params) /*: ?int*/ + { + $this->sanitizeRecord($tableName, $record, $id); + $table = $this->reflection->getTable($tableName); + $columnValues = $this->columns->getValues($table, true, $record, $params); + return $this->db->incrementSingle($table, $columnValues, $id); + } + + public function _list(string $tableName, array $params): ListDocument + { + $table = $this->reflection->getTable($tableName); + $this->joiner->addMandatoryColumns($table, $params); + $columnNames = $this->columns->getNames($table, true, $params); + $condition = $this->filters->getCombinedConditions($table, $params); + $columnOrdering = $this->ordering->getColumnOrdering($table, $params); + if (!$this->pagination->hasPage($params)) { + $offset = 0; + $limit = $this->pagination->getPageLimit($params); + $count = 0; + } else { + $offset = $this->pagination->getPageOffset($params); + $limit = $this->pagination->getPageLimit($params); + $count = $this->db->selectCount($table, $condition); + } + $records = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, $offset, $limit); + $this->joiner->addJoins($table, $records, $params, $this->db); + return new ListDocument($records, $count); + } +} diff --git a/src/Tqdev/PhpCrudApi/Record/RelationJoiner.php b/src/Tqdev/PhpCrudApi/Record/RelationJoiner.php new file mode 100644 index 0000000..df53242 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Record/RelationJoiner.php @@ -0,0 +1,296 @@ +reflection = $reflection; + $this->ordering = new OrderingInfo(); + $this->columns = $columns; + } + + public function addMandatoryColumns(ReflectedTable $table, array &$params) /*: void*/ + { + if (!isset($params['join']) || !isset($params['include'])) { + return; + } + $params['mandatory'] = array(); + foreach ($params['join'] as $tableNames) { + $t1 = $table; + foreach (explode(',', $tableNames) as $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $t2 = $this->reflection->getTable($tableName); + $fks1 = $t1->getFksTo($t2->getName()); + $t3 = $this->hasAndBelongsToMany($t1, $t2); + if ($t3 != null || count($fks1) > 0) { + $params['mandatory'][] = $t2->getName() . '.' . $t2->getPk()->getName(); + } + foreach ($fks1 as $fk) { + $params['mandatory'][] = $t1->getName() . '.' . $fk->getName(); + } + $fks2 = $t2->getFksTo($t1->getName()); + if ($t3 != null || count($fks2) > 0) { + $params['mandatory'][] = $t1->getName() . '.' . $t1->getPk()->getName(); + } + foreach ($fks2 as $fk) { + $params['mandatory'][] = $t2->getName() . '.' . $fk->getName(); + } + $t1 = $t2; + } + } + } + + private function getJoinsAsPathTree(array $params): PathTree + { + $joins = new PathTree(); + if (isset($params['join'])) { + foreach ($params['join'] as $tableNames) { + $path = array(); + foreach (explode(',', $tableNames) as $tableName) { + if (!$this->reflection->hasTable($tableName)) { + continue; + } + $t = $this->reflection->getTable($tableName); + if ($t != null) { + $path[] = $t->getName(); + } + } + $joins->put($path, true); + } + } + return $joins; + } + + public function addJoins(ReflectedTable $table, array &$records, array $params, GenericDB $db) /*: void*/ + { + $joins = $this->getJoinsAsPathTree($params); + $this->addJoinsForTables($table, $joins, $records, $params, $db); + } + + private function hasAndBelongsToMany(ReflectedTable $t1, ReflectedTable $t2) /*: ?ReflectedTable*/ + { + foreach ($this->reflection->getTableNames() as $tableName) { + $t3 = $this->reflection->getTable($tableName); + if (count($t3->getFksTo($t1->getName())) > 0 && count($t3->getFksTo($t2->getName())) > 0) { + return $t3; + } + } + return null; + } + + private function addJoinsForTables(ReflectedTable $t1, PathTree $joins, array &$records, array $params, GenericDB $db) + { + foreach ($joins->getKeys() as $t2Name) { + $t2 = $this->reflection->getTable($t2Name); + + $belongsTo = count($t1->getFksTo($t2->getName())) > 0; + $hasMany = count($t2->getFksTo($t1->getName())) > 0; + if (!$belongsTo && !$hasMany) { + $t3 = $this->hasAndBelongsToMany($t1, $t2); + } else { + $t3 = null; + } + $hasAndBelongsToMany = ($t3 != null); + + $newRecords = array(); + $fkValues = null; + $pkValues = null; + $habtmValues = null; + + if ($belongsTo) { + $fkValues = $this->getFkEmptyValues($t1, $t2, $records); + $this->addFkRecords($t2, $fkValues, $params, $db, $newRecords); + } + if ($hasMany) { + $pkValues = $this->getPkEmptyValues($t1, $records); + $this->addPkRecords($t1, $t2, $pkValues, $params, $db, $newRecords); + } + if ($hasAndBelongsToMany) { + $habtmValues = $this->getHabtmEmptyValues($t1, $t2, $t3, $db, $records); + $this->addFkRecords($t2, $habtmValues->fkValues, $params, $db, $newRecords); + } + + $this->addJoinsForTables($t2, $joins->get($t2Name), $newRecords, $params, $db); + + if ($fkValues != null) { + $this->fillFkValues($t2, $newRecords, $fkValues); + $this->setFkValues($t1, $t2, $records, $fkValues); + } + if ($pkValues != null) { + $this->fillPkValues($t1, $t2, $newRecords, $pkValues); + $this->setPkValues($t1, $t2, $records, $pkValues); + } + if ($habtmValues != null) { + $this->fillFkValues($t2, $newRecords, $habtmValues->fkValues); + $this->setHabtmValues($t1, $t2, $records, $habtmValues); + } + } + } + + private function getFkEmptyValues(ReflectedTable $t1, ReflectedTable $t2, array $records): array + { + $fkValues = array(); + $fks = $t1->getFksTo($t2->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($records as $record) { + if (isset($record[$fkName])) { + $fkValue = $record[$fkName]; + $fkValues[$fkValue] = null; + } + } + } + return $fkValues; + } + + private function addFkRecords(ReflectedTable $t2, array $fkValues, array $params, GenericDB $db, array &$records) /*: void*/ + { + $columnNames = $this->columns->getNames($t2, false, $params); + $fkIds = array_keys($fkValues); + + foreach ($db->selectMultiple($t2, $columnNames, $fkIds) as $record) { + $records[] = $record; + } + } + + private function fillFkValues(ReflectedTable $t2, array $fkRecords, array &$fkValues) /*: void*/ + { + $pkName = $t2->getPk()->getName(); + foreach ($fkRecords as $fkRecord) { + $pkValue = $fkRecord[$pkName]; + $fkValues[$pkValue] = $fkRecord; + } + } + + private function setFkValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, array $fkValues) /*: void*/ + { + $fks = $t1->getFksTo($t2->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($records as $i => $record) { + if (isset($record[$fkName])) { + $key = $record[$fkName]; + $records[$i][$fkName] = $fkValues[$key]; + } + } + } + } + + private function getPkEmptyValues(ReflectedTable $t1, array $records): array + { + $pkValues = array(); + $pkName = $t1->getPk()->getName(); + foreach ($records as $record) { + $key = $record[$pkName]; + $pkValues[$key] = array(); + } + return $pkValues; + } + + private function addPkRecords(ReflectedTable $t1, ReflectedTable $t2, array $pkValues, array $params, GenericDB $db, array &$records) /*: void*/ + { + $fks = $t2->getFksTo($t1->getName()); + $columnNames = $this->columns->getNames($t2, false, $params); + $pkValueKeys = implode(',', array_keys($pkValues)); + $conditions = array(); + foreach ($fks as $fk) { + $conditions[] = new ColumnCondition($fk, 'in', $pkValueKeys); + } + $condition = OrCondition::fromArray($conditions); + $columnOrdering = array(); + $limit = VariableStore::get("joinLimits.maxRecords") ?: -1; + if ($limit != -1) { + $columnOrdering = $this->ordering->getDefaultColumnOrdering($t2); + } + foreach ($db->selectAll($t2, $columnNames, $condition, $columnOrdering, 0, $limit) as $record) { + $records[] = $record; + } + } + + private function fillPkValues(ReflectedTable $t1, ReflectedTable $t2, array $pkRecords, array &$pkValues) /*: void*/ + { + $fks = $t2->getFksTo($t1->getName()); + foreach ($fks as $fk) { + $fkName = $fk->getName(); + foreach ($pkRecords as $pkRecord) { + $key = $pkRecord[$fkName]; + if (isset($pkValues[$key])) { + $pkValues[$key][] = $pkRecord; + } + } + } + } + + private function setPkValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, array $pkValues) /*: void*/ + { + $pkName = $t1->getPk()->getName(); + $t2Name = $t2->getName(); + + foreach ($records as $i => $record) { + $key = $record[$pkName]; + $records[$i][$t2Name] = $pkValues[$key]; + } + } + + private function getHabtmEmptyValues(ReflectedTable $t1, ReflectedTable $t2, ReflectedTable $t3, GenericDB $db, array $records): HabtmValues + { + $pkValues = $this->getPkEmptyValues($t1, $records); + $fkValues = array(); + + $fk1 = $t3->getFksTo($t1->getName())[0]; + $fk2 = $t3->getFksTo($t2->getName())[0]; + + $fk1Name = $fk1->getName(); + $fk2Name = $fk2->getName(); + + $columnNames = array($fk1Name, $fk2Name); + + $pkIds = implode(',', array_keys($pkValues)); + $condition = new ColumnCondition($t3->getColumn($fk1Name), 'in', $pkIds); + $columnOrdering = array(); + + $limit = VariableStore::get("joinLimits.maxRecords") ?: -1; + if ($limit != -1) { + $columnOrdering = $this->ordering->getDefaultColumnOrdering($t3); + } + $records = $db->selectAll($t3, $columnNames, $condition, $columnOrdering, 0, $limit); + foreach ($records as $record) { + $val1 = $record[$fk1Name]; + $val2 = $record[$fk2Name]; + $pkValues[$val1][] = $val2; + $fkValues[$val2] = null; + } + + return new HabtmValues($pkValues, $fkValues); + } + + private function setHabtmValues(ReflectedTable $t1, ReflectedTable $t2, array &$records, HabtmValues $habtmValues) /*: void*/ + { + $pkName = $t1->getPk()->getName(); + $t2Name = $t2->getName(); + foreach ($records as $i => $record) { + $key = $record[$pkName]; + $val = array(); + $fks = $habtmValues->pkValues[$key]; + foreach ($fks as $fk) { + $val[] = $habtmValues->fkValues[$fk]; + } + $records[$i][$t2Name] = $val; + } + } +} diff --git a/src/Tqdev/PhpCrudApi/RequestFactory.php b/src/Tqdev/PhpCrudApi/RequestFactory.php new file mode 100644 index 0000000..254039c --- /dev/null +++ b/src/Tqdev/PhpCrudApi/RequestFactory.php @@ -0,0 +1,43 @@ +fromGlobals(); + $stream = $psr17Factory->createStreamFromFile('php://input'); + $serverRequest = $serverRequest->withBody($stream); + return $serverRequest; + } + + public static function fromString(string $request): ServerRequestInterface + { + $parts = explode("\n\n", trim($request), 2); + $lines = explode("\n", $parts[0]); + $first = explode(' ', trim(array_shift($lines)), 2); + $method = $first[0]; + $body = isset($parts[1]) ? $parts[1] : ''; + $url = isset($first[1]) ? $first[1] : ''; + + $psr17Factory = new Psr17Factory(); + $serverRequest = $psr17Factory->createServerRequest($method, $url); + foreach ($lines as $line) { + list($key, $value) = explode(':', $line, 2); + $serverRequest = $serverRequest->withAddedHeader($key, $value); + } + if ($body) { + $stream = $psr17Factory->createStream($body); + $stream->rewind(); + $serverRequest = $serverRequest->withBody($stream); + } + return $serverRequest; + } +} diff --git a/src/Tqdev/PhpCrudApi/RequestUtils.php b/src/Tqdev/PhpCrudApi/RequestUtils.php new file mode 100644 index 0000000..f7ed557 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/RequestUtils.php @@ -0,0 +1,100 @@ +withUri($request->getUri()->withQuery($query)); + } + + public static function getHeader(ServerRequestInterface $request, string $header): string + { + $headers = $request->getHeader($header); + return isset($headers[0]) ? $headers[0] : ''; + } + + public static function getParams(ServerRequestInterface $request): array + { + $params = array(); + $query = $request->getUri()->getQuery(); + //$query = str_replace('][]=', ']=', str_replace('=', '[]=', $query)); + $query = str_replace('%5D%5B%5D=', '%5D=', str_replace('=', '%5B%5D=', $query)); + parse_str($query, $params); + return $params; + } + + public static function getPathSegment(ServerRequestInterface $request, int $part): string + { + $path = $request->getUri()->getPath(); + $pathSegments = explode('/', rtrim($path, '/')); + if ($part < 0 || $part >= count($pathSegments)) { + return ''; + } + return urldecode($pathSegments[$part]); + } + + public static function getOperation(ServerRequestInterface $request): string + { + $method = $request->getMethod(); + $path = RequestUtils::getPathSegment($request, 1); + $hasPk = RequestUtils::getPathSegment($request, 3) != ''; + switch ($path) { + case 'openapi': + return 'document'; + case 'columns': + return $method == 'get' ? 'reflect' : 'remodel'; + case 'geojson': + case 'records': + switch ($method) { + case 'POST': + return 'create'; + case 'GET': + return $hasPk ? 'read' : 'list'; + case 'PUT': + return 'update'; + case 'DELETE': + return 'delete'; + case 'PATCH': + return 'increment'; + } + } + return 'unknown'; + } + + private static function getJoinTables(string $tableName, array $parameters): array + { + $uniqueTableNames = array(); + $uniqueTableNames[$tableName] = true; + if (isset($parameters['join'])) { + foreach ($parameters['join'] as $parameter) { + $tableNames = explode(',', trim($parameter)); + foreach ($tableNames as $tableName) { + $uniqueTableNames[$tableName] = true; + } + } + } + return array_keys($uniqueTableNames); + } + + public static function getTableNames(ServerRequestInterface $request, ReflectionService $reflection): array + { + $path = RequestUtils::getPathSegment($request, 1); + $tableName = RequestUtils::getPathSegment($request, 2); + $allTableNames = $reflection->getTableNames(); + switch ($path) { + case 'openapi': + return $allTableNames; + case 'columns': + return $tableName ? [$tableName] : $allTableNames; + case 'records': + return self::getJoinTables($tableName, RequestUtils::getParams($request)); + } + return $allTableNames; + } +} diff --git a/src/Tqdev/PhpCrudApi/ResponseFactory.php b/src/Tqdev/PhpCrudApi/ResponseFactory.php new file mode 100644 index 0000000..ceebd41 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/ResponseFactory.php @@ -0,0 +1,59 @@ +createResponse($status); + $stream = $psr17Factory->createStream($content); + $stream->rewind(); + $response = $response->withBody($stream); + $response = $response->withHeader('Content-Type', $contentType . '; charset=utf-8'); + $response = $response->withHeader('Content-Length', strlen($content)); + return $response; + } + + public static function fromStatus(int $status): ResponseInterface + { + $psr17Factory = new Psr17Factory(); + return $psr17Factory->createResponse($status); + } +} diff --git a/src/Tqdev/PhpCrudApi/ResponseUtils.php b/src/Tqdev/PhpCrudApi/ResponseUtils.php new file mode 100644 index 0000000..994e8ef --- /dev/null +++ b/src/Tqdev/PhpCrudApi/ResponseUtils.php @@ -0,0 +1,50 @@ +getStatusCode(); + $headers = $response->getHeaders(); + $body = $response->getBody()->getContents(); + + http_response_code($status); + foreach ($headers as $key => $values) { + foreach ($values as $value) { + header("$key: $value"); + } + } + echo $body; + } + + public static function addExceptionHeaders(ResponseInterface $response, \Throwable $e): ResponseInterface + { + $response = $response->withHeader('X-Exception-Name', get_class($e)); + $response = $response->withHeader('X-Exception-Message', preg_replace('|\n|', ' ', trim($e->getMessage()))); + $response = $response->withHeader('X-Exception-File', $e->getFile() . ':' . $e->getLine()); + return $response; + } + + public static function toString(ResponseInterface $response): string + { + $status = $response->getStatusCode(); + $headers = $response->getHeaders(); + $body = $response->getBody()->getContents(); + + $str = "$status\n"; + foreach ($headers as $key => $values) { + foreach ($values as $value) { + $str .= "$key: $value\n"; + } + } + if ($body !== '') { + $str .= "\n"; + $str .= "$body\n"; + } + return $str; + } +} diff --git a/src/index.php b/src/index.php new file mode 100644 index 0000000..88f9afe --- /dev/null +++ b/src/index.php @@ -0,0 +1,24 @@ + 'mysql', + // 'address' => 'localhost', + // 'port' => '3306', + 'username' => 'php-crud-api', + 'password' => 'php-crud-api', + 'database' => 'php-crud-api', + // 'debug' => false +]); +$request = RequestFactory::fromGlobals(); +$api = new Api($config); +$response = $api->handle($request); +ResponseUtils::output($response); diff --git a/test.php b/test.php new file mode 100644 index 0000000..3a817ee --- /dev/null +++ b/test.php @@ -0,0 +1,202 @@ +getDriver(); + foreach ($headers as $header) { + if (!strpos($header, ':')) { + continue; + } + list($key, $value) = explode(':', strtolower($header)); + if ($key == "skip-for-$driver") { + $skipped = 1; + $success = 0; + } + if ($key == "skip-always") { + $skipped = 1; + $success = 0; + } + } + if (!$skipped) { + $dirty = false; + for ($i = 1; $i < count($parts); $i += 2) { + $recording = false; + if (empty($parts[$i + 1])) { + if (substr($parts[$i], -1) != "\n") { + $parts[$i] .= "\n"; + } + $parts[$i + 1] = ''; + $recording = true; + $dirty = true; + } + $in = $parts[$i]; + $exp = $parts[$i + 1]; + $api = new Api($config); + $_SERVER['REMOTE_ADDR'] = 'TEST_IP'; + $out = ResponseUtils::toString($api->handle(RequestFactory::fromString($in))); + if ($recording) { + $parts[$i + 1] = $out; + } else if ($out != $exp) { + echo "$line1\n$exp\n$line2\n$out\n$line2\n"; + $failed = 1; + $success = 0; + } + } + if ($dirty) { + file_put_contents($file, implode("===\n", $parts)); + } + } + return compact('success', 'skipped', 'failed'); +} + +function getDatabase(Config $config) +{ + if (!isset($config->getMiddlewares()['reconnect']['databaseHandler'])) { + return $config->getDatabase(); + } + return $config->getMiddlewares()['reconnect']['databaseHandler'](); +} + +function getTables(Config $config) +{ + if (!isset($config->getMiddlewares()['reconnect']['tablesHandler'])) { + return $config->getTables(); + } + return $config->getMiddlewares()['reconnect']['tablesHandler'](); +} + +function getUsername(Config $config) +{ + if (!isset($config->getMiddlewares()['reconnect']['usernameHandler'])) { + return $config->getUsername(); + } + return $config->getMiddlewares()['reconnect']['usernameHandler'](); +} + +function getPassword(Config $config) +{ + if (!isset($config->getMiddlewares()['reconnect']['passwordHandler'])) { + return $config->getPassword(); + } + return $config->getMiddlewares()['reconnect']['passwordHandler'](); +} + + +function loadFixture(string $dir, Config $config) +{ + $driver = $config->getDriver(); + $filename = "$dir/fixtures/blog_$driver.sql"; + $file = file_get_contents($filename); + $db = new GenericDB( + $config->getDriver(), + $config->getAddress(), + $config->getPort(), + getDatabase($config), + getTables($config), + getUsername($config), + getPassword($config) + ); + $pdo = $db->pdo(); + $file = preg_replace('/--.*$/m', '', $file); + if ($driver == 'sqlsrv') { + $statements = preg_split('/\n\s*GO\s*\n/s', $file); + } else { + $statements = preg_split('/(?<=;)\n/s', $file); + } + foreach ($statements as $i => $statement) { + $statement = trim($statement); + if ($statement) { + try { + $pdo->exec($statement); + } catch (\PDOException $e) { + $error = print_r($pdo->errorInfo(), true); + $statement = var_export($statement, true); + echo "Loading '$filename' failed on statemement #$i:\n$statement\nwith error:\n$error\n"; + exit(1); + } + } + } +} + +function run(array $drivers, string $dir, array $matches) +{ + foreach ($drivers as $driver) { + if (isset($matches[0])) { + if (!preg_match('/' . $matches[0] . '/', $driver)) { + continue; + } + } + if (!extension_loaded("pdo_$driver")) { + echo sprintf("%s: skipped, driver not loaded\n", $driver); + continue; + } + $settings = []; + include "$dir/config/base.php"; + include sprintf("$dir/config/%s.php", $driver); + $config = new Config($settings); + loadFixture($dir, $config); + $start = microtime(true); + $statistics = runDir($config, "$dir/functional", array_slice($matches, 1), ''); + $end = microtime(true); + $time = ($end - $start) * 1000; + $success = $statistics['success']; + $skipped = $statistics['skipped']; + $failed = $statistics['failed']; + $total = $success + $skipped + $failed; + echo sprintf("%s: %d tests ran in %d ms, %d skipped, %d failed\n", $driver, $total, $time, $skipped, $failed); + } +} + +run(['mysql', 'pgsql', 'sqlsrv', 'sqlite'], __DIR__ . '/tests', array_slice($argv, 1)); diff --git a/tests/config/.htpasswd b/tests/config/.htpasswd new file mode 100644 index 0000000..e1a019a --- /dev/null +++ b/tests/config/.htpasswd @@ -0,0 +1 @@ +username1:$2y$10$Qov96xrFqrbaTu3e87SUD.ZH5MGrJ5q/xSDMoKxgZhK2H7TMNuVym diff --git a/tests/config/base.php b/tests/config/base.php new file mode 100644 index 0000000..3e2e053 --- /dev/null +++ b/tests/config/base.php @@ -0,0 +1,61 @@ + 'incorrect_database', + 'username' => 'incorrect_username', + 'password' => 'incorrect_password', + 'controllers' => 'records,columns,cache,openapi,geojson', + 'middlewares' => 'sslRedirect,xml,cors,reconnect,dbAuth,jwtAuth,basicAuth,authorization,sanitation,validation,ipAddress,multiTenancy,pageLimits,joinLimits,customization', + 'dbAuth.mode' => 'optional', + 'dbAuth.returnedColumns' => 'id,username,password', + 'dbAuth.registerUser' => '1', + 'dbAuth.passwordLength' => '4', + 'jwtAuth.mode' => 'optional', + 'jwtAuth.time' => '1538207605', + 'jwtAuth.secrets' => 'axpIrCGNGqxzx2R9dtXLIPUSqPo778uhb8CA0F4Hx', + 'basicAuth.mode' => 'optional', + 'basicAuth.passwordFile' => __DIR__ . DIRECTORY_SEPARATOR . '.htpasswd', + 'reconnect.databaseHandler' => function () { + return 'php-crud-api'; + }, + 'reconnect.usernameHandler' => function () { + return 'php-crud-api'; + }, + 'reconnect.passwordHandler' => function () { + return 'php-crud-api'; + }, + 'authorization.tableHandler' => function ($operation, $tableName) { + return !($tableName == 'invisibles' && !isset($_SESSION['claims']['name']) && empty($_SESSION['username']) && empty($_SESSION['user'])); + }, + 'authorization.columnHandler' => function ($operation, $tableName, $columnName) { + return !($columnName == 'invisible'); + }, + 'authorization.recordHandler' => function ($operation, $tableName) { + return ($tableName == 'comments') ? 'filter=message,neq,invisible' : ''; + }, + 'ipAddress.tables' => 'barcodes', + 'ipAddress.columns' => 'ip_address', + 'sanitation.handler' => function ($operation, $tableName, $column, $value) { + return is_string($value) ? strip_tags($value) : $value; + }, + 'sanitation.tables' => 'forgiving', + 'validation.handler' => function ($operation, $tableName, $column, $value, $context) { + return ($column['name'] == 'post_id' && !is_numeric($value)) ? 'must be numeric' : true; + }, + 'multiTenancy.handler' => function ($operation, $tableName) { + return ($tableName == 'kunsthåndværk') ? ['user_id' => 1] : []; + }, + 'pageLimits.pages' => 5, + 'pageLimits.records' => 10, + 'joinLimits.depth' => 2, + 'joinLimits.tables' => 4, + 'joinLimits.records' => 10, + 'customization.beforeHandler' => function ($operation, $tableName, $request, $environment) { + $environment->start = 0.003/*microtime(true)*/; + }, + 'customization.afterHandler' => function ($operation, $tableName, $response, $environment) { + if ($tableName == 'kunsthåndværk' && $operation == 'increment') { + return $response->withHeader('X-Time-Taken', 0.006/*microtime(true)*/ - $environment->start); + } + }, + 'debug' => false, +]; diff --git a/tests/config/mysql.php b/tests/config/mysql.php new file mode 100644 index 0000000..d3805cb --- /dev/null +++ b/tests/config/mysql.php @@ -0,0 +1,2 @@ +falseTRX-120100', '1970-01-01 01:01:01') +GO + +INSERT [barcodes] ([product_id], [hex], [bin], [ip_address]) VALUES (1, N'00ff01', 0x00ff01, N'127.0.0.1') +GO + +INSERT [kunsthåndværk] ([id], [Umlauts ä_ö_ü-COUNT], [user_id], [invisible], [invisible_id]) VALUES ('e42c77c6-06a4-4502-816c-d112c7142e6d', 1, 1, NULL, 'e42c77c6-06a4-4502-816c-d112c7142e6d') +GO +INSERT [kunsthåndværk] ([id], [Umlauts ä_ö_ü-COUNT], [user_id], [invisible], [invisible_id]) VALUES ('e31ecfe6-591f-4660-9fbd-1a232083037f', 2, 2, NULL, 'e42c77c6-06a4-4502-816c-d112c7142e6d') +GO + +INSERT [invisibles] ([id]) VALUES ('e42c77c6-06a4-4502-816c-d112c7142e6d') +GO + +INSERT [nopk] ([id]) VALUES ('e42c77c6-06a4-4502-816c-d112c7142e6d') +GO + +ALTER TABLE [comments] WITH CHECK ADD CONSTRAINT [comments_post_id_fkey] FOREIGN KEY([post_id]) +REFERENCES [posts] ([id]) +GO +ALTER TABLE [comments] CHECK CONSTRAINT [comments_post_id_fkey] +GO + +ALTER TABLE [comments] WITH CHECK ADD CONSTRAINT [comments_category_id_fkey] FOREIGN KEY([category_id]) +REFERENCES [categories] ([id]) +GO +ALTER TABLE [comments] CHECK CONSTRAINT [comments_category_id_fkey] +GO + +ALTER TABLE [post_tags] WITH CHECK ADD CONSTRAINT [post_tags_post_id_fkey] FOREIGN KEY([post_id]) +REFERENCES [posts] ([id]) +GO +ALTER TABLE [post_tags] CHECK CONSTRAINT [post_tags_post_id_fkey] +GO + +ALTER TABLE [post_tags] WITH CHECK ADD CONSTRAINT [post_tags_tag_id_fkey] FOREIGN KEY([tag_id]) +REFERENCES [tags] ([id]) +GO +ALTER TABLE [post_tags] CHECK CONSTRAINT [post_tags_tag_id_fkey] +GO + +ALTER TABLE [posts] WITH CHECK ADD CONSTRAINT [posts_category_id_fkey] FOREIGN KEY([category_id]) +REFERENCES [categories] ([id]) +GO +ALTER TABLE [posts] CHECK CONSTRAINT [posts_category_id_fkey] +GO + +ALTER TABLE [posts] WITH CHECK ADD CONSTRAINT [posts_user_id_fkey] FOREIGN KEY([user_id]) +REFERENCES [users] ([id]) +GO +ALTER TABLE [posts] CHECK CONSTRAINT [posts_user_id_fkey] +GO + +ALTER TABLE [barcodes] WITH CHECK ADD CONSTRAINT [barcodes_product_id_fkey] FOREIGN KEY([product_id]) +REFERENCES [products] ([id]) +GO +ALTER TABLE [barcodes] CHECK CONSTRAINT [barcodes_product_id_fkey] +GO + +ALTER TABLE [kunsthåndværk] WITH CHECK ADD CONSTRAINT [UC_kunsthåndværk_Umlauts ä_ö_ü-COUNT] UNIQUE([Umlauts ä_ö_ü-COUNT]) +GO + +ALTER TABLE [kunsthåndværk] WITH CHECK ADD CONSTRAINT [kunsthåndværk_user_id_fkey] FOREIGN KEY([user_id]) +REFERENCES [users] ([id]) +GO +ALTER TABLE [kunsthåndværk] CHECK CONSTRAINT [kunsthåndværk_user_id_fkey] +GO + +ALTER TABLE [kunsthåndværk] WITH CHECK ADD CONSTRAINT [kunsthåndværk_invisible_id_fkey] FOREIGN KEY([invisible_id]) +REFERENCES [invisibles] ([id]) +GO +ALTER TABLE [kunsthåndværk] CHECK CONSTRAINT [kunsthåndværk_invisible_id_fkey] +GO diff --git a/tests/fixtures/create_mysql.sql b/tests/fixtures/create_mysql.sql new file mode 100644 index 0000000..e283599 --- /dev/null +++ b/tests/fixtures/create_mysql.sql @@ -0,0 +1,4 @@ +CREATE DATABASE `php-crud-api` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +CREATE USER 'php-crud-api'@'localhost' IDENTIFIED BY 'php-crud-api'; +GRANT ALL PRIVILEGES ON `php-crud-api`.* TO 'php-crud-api'@'localhost' WITH GRANT OPTION; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/tests/fixtures/create_pgsql.sql b/tests/fixtures/create_pgsql.sql new file mode 100644 index 0000000..da8358b --- /dev/null +++ b/tests/fixtures/create_pgsql.sql @@ -0,0 +1,5 @@ +CREATE USER "php-crud-api" WITH PASSWORD 'php-crud-api'; +CREATE DATABASE "php-crud-api"; +GRANT ALL PRIVILEGES ON DATABASE "php-crud-api" to "php-crud-api"; +\c "php-crud-api"; +CREATE EXTENSION IF NOT EXISTS postgis; \ No newline at end of file diff --git a/tests/fixtures/create_sqlsrv.sql b/tests/fixtures/create_sqlsrv.sql new file mode 100644 index 0000000..a22dff4 --- /dev/null +++ b/tests/fixtures/create_sqlsrv.sql @@ -0,0 +1,9 @@ +CREATE DATABASE [php-crud-api] +GO +CREATE LOGIN [php-crud-api] WITH PASSWORD=N'php-crud-api', DEFAULT_DATABASE=[php-crud-api], CHECK_EXPIRATION=OFF, CHECK_POLICY=OFF +GO +USE [php-crud-api] +GO +CREATE USER [php-crud-api] FOR LOGIN [php-crud-api] WITH DEFAULT_SCHEMA=[dbo] +exec sp_addrolemember 'db_owner', 'php-crud-api'; +GO \ No newline at end of file diff --git a/tests/functional/001_records/001_list_posts.log b/tests/functional/001_records/001_list_posts.log new file mode 100644 index 0000000..85ed0a9 --- /dev/null +++ b/tests/functional/001_records/001_list_posts.log @@ -0,0 +1,8 @@ +=== +GET /records/posts +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 134 + +{"records":[{"id":1,"user_id":1,"category_id":1,"content":"blog started"},{"id":2,"user_id":1,"category_id":2,"content":"It works!"}]} diff --git a/tests/functional/001_records/002_list_post_columns.log b/tests/functional/001_records/002_list_post_columns.log new file mode 100644 index 0000000..3d0a723 --- /dev/null +++ b/tests/functional/001_records/002_list_post_columns.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?include=id,content +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 78 + +{"records":[{"id":1,"content":"blog started"},{"id":2,"content":"It works!"}]} diff --git a/tests/functional/001_records/003_read_post.log b/tests/functional/001_records/003_read_post.log new file mode 100644 index 0000000..86c05c6 --- /dev/null +++ b/tests/functional/001_records/003_read_post.log @@ -0,0 +1,16 @@ +=== +GET /records/posts/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 58 + +{"id":2,"user_id":1,"category_id":2,"content":"It works!"} +=== +GET /records/posts/0 +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 46 + +{"code":1003,"message":"Record '0' not found"} diff --git a/tests/functional/001_records/004_read_posts.log b/tests/functional/001_records/004_read_posts.log new file mode 100644 index 0000000..3503046 --- /dev/null +++ b/tests/functional/001_records/004_read_posts.log @@ -0,0 +1,8 @@ +=== +GET /records/posts/1,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 122 + +[{"id":1,"user_id":1,"category_id":1,"content":"blog started"},{"id":2,"user_id":1,"category_id":2,"content":"It works!"}] diff --git a/tests/functional/001_records/005_read_post_columns.log b/tests/functional/001_records/005_read_post_columns.log new file mode 100644 index 0000000..92f9c6e --- /dev/null +++ b/tests/functional/001_records/005_read_post_columns.log @@ -0,0 +1,8 @@ +=== +GET /records/posts/2?include=id,content +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 30 + +{"id":2,"content":"It works!"} diff --git a/tests/functional/001_records/006_add_post.log b/tests/functional/001_records/006_add_post.log new file mode 100644 index 0000000..456d3af --- /dev/null +++ b/tests/functional/001_records/006_add_post.log @@ -0,0 +1,11 @@ +=== +POST /records/posts +Content-Type: application/json; charset=utf-8 + +{"user_id":1,"category_id":1,"content":"test"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +3 diff --git a/tests/functional/001_records/007_edit_post.log b/tests/functional/001_records/007_edit_post.log new file mode 100644 index 0000000..8376ba5 --- /dev/null +++ b/tests/functional/001_records/007_edit_post.log @@ -0,0 +1,18 @@ +=== +PUT /records/posts/3 + +{"user_id":1,"category_id":1,"content":"test (edited)"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/posts/3 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 62 + +{"id":3,"user_id":1,"category_id":1,"content":"test (edited)"} diff --git a/tests/functional/001_records/008_edit_post_columns_missing_field.log b/tests/functional/001_records/008_edit_post_columns_missing_field.log new file mode 100644 index 0000000..f14a1d8 --- /dev/null +++ b/tests/functional/001_records/008_edit_post_columns_missing_field.log @@ -0,0 +1,18 @@ +=== +PUT /records/posts/3?include=id,content + +{"content":"test (edited 2)"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/posts/3 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 64 + +{"id":3,"user_id":1,"category_id":1,"content":"test (edited 2)"} diff --git a/tests/functional/001_records/009_edit_post_columns_extra_field.log b/tests/functional/001_records/009_edit_post_columns_extra_field.log new file mode 100644 index 0000000..7278b6a --- /dev/null +++ b/tests/functional/001_records/009_edit_post_columns_extra_field.log @@ -0,0 +1,18 @@ +=== +PUT /records/posts/3?include=id,content + +{"user_id":2,"content":"test (edited 3)"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/posts/3 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 64 + +{"id":3,"user_id":1,"category_id":1,"content":"test (edited 3)"} diff --git a/tests/functional/001_records/010_edit_post_with_utf8_content.log b/tests/functional/001_records/010_edit_post_with_utf8_content.log new file mode 100644 index 0000000..c790cf7 --- /dev/null +++ b/tests/functional/001_records/010_edit_post_with_utf8_content.log @@ -0,0 +1,18 @@ +=== +PUT /records/posts/2 + +{"content":"🤗 Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/posts/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 192 + +{"id":2,"user_id":1,"category_id":2,"content":"🤗 Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте"} diff --git a/tests/functional/001_records/011_edit_post_with_utf8_content_with_post.log b/tests/functional/001_records/011_edit_post_with_utf8_content_with_post.log new file mode 100644 index 0000000..008cf00 --- /dev/null +++ b/tests/functional/001_records/011_edit_post_with_utf8_content_with_post.log @@ -0,0 +1,19 @@ +=== +PUT /records/posts/2 +Content-Type: application/x-www-form-urlencoded + +content=%F0%9F%A6%80%E2%82%AC%20Gr%C3%BC%C3%9Fgott%2C%20%D0%92i%D1%82%D0%B0%D1%8E%2C%20dobr%C3%BD%20de%C5%88%2C%20hyv%C3%A4%C3%A4%20p%C3%A4iv%C3%A4%C3%A4%2C%20%E1%83%92%E1%83%90%E1%83%9B%E1%83%90%E1%83%A0%E1%83%AF%E1%83%9D%E1%83%91%E1%83%90%2C%20%CE%93%CE%B5%CE%B9%CE%B1%20%CF%83%CE%B1%CF%82%2C%20g%C3%B3%C3%B0an%20dag%2C%20%D0%B7%D0%B4%D1%80%D0%B0%D0%B2%D1%81%D1%82%D0%B2%D1%83%D0%B9%D1%82%D0%B5 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/posts/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 195 + +{"id":2,"user_id":1,"category_id":2,"content":"🦀€ Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте"} diff --git a/tests/functional/001_records/012_delete_post.log b/tests/functional/001_records/012_delete_post.log new file mode 100644 index 0000000..7ce3991 --- /dev/null +++ b/tests/functional/001_records/012_delete_post.log @@ -0,0 +1,16 @@ +=== +DELETE /records/posts/3 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/posts/3 +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 46 + +{"code":1003,"message":"Record '3' not found"} diff --git a/tests/functional/001_records/013_add_post_with_post.log b/tests/functional/001_records/013_add_post_with_post.log new file mode 100644 index 0000000..415739a --- /dev/null +++ b/tests/functional/001_records/013_add_post_with_post.log @@ -0,0 +1,10 @@ +=== +POST /records/posts + +user_id=1&category_id=1&content=test +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +4 diff --git a/tests/functional/001_records/014_edit_post_with_post.log b/tests/functional/001_records/014_edit_post_with_post.log new file mode 100644 index 0000000..d90df32 --- /dev/null +++ b/tests/functional/001_records/014_edit_post_with_post.log @@ -0,0 +1,18 @@ +=== +PUT /records/posts/4 + +user_id=1&category_id=1&content=test+(edited) +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/posts/4 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 62 + +{"id":4,"user_id":1,"category_id":1,"content":"test (edited)"} diff --git a/tests/functional/001_records/015_delete_post_ignore_columns.log b/tests/functional/001_records/015_delete_post_ignore_columns.log new file mode 100644 index 0000000..a5e6292 --- /dev/null +++ b/tests/functional/001_records/015_delete_post_ignore_columns.log @@ -0,0 +1,16 @@ +=== +DELETE /records/posts/4?include=id,content +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/posts/4 +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 46 + +{"code":1003,"message":"Record '4' not found"} diff --git a/tests/functional/001_records/016_list_with_paginate.log b/tests/functional/001_records/016_list_with_paginate.log new file mode 100644 index 0000000..87f6626 --- /dev/null +++ b/tests/functional/001_records/016_list_with_paginate.log @@ -0,0 +1,108 @@ +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#1"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +5 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#2"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +6 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#3"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +7 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#4"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +8 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#5"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +9 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#6"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 2 + +10 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#7"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 2 + +11 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#8"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 2 + +12 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#9"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 2 + +13 +=== +POST /records/posts + +{"user_id":1,"category_id":1,"content":"#10"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 2 + +14 +=== +GET /records/posts?page=2,2&order=id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 130 + +{"records":[{"id":5,"user_id":1,"category_id":1,"content":"#1"},{"id":6,"user_id":1,"category_id":1,"content":"#2"}],"results":12} diff --git a/tests/functional/001_records/017_edit_post_primary_key.log b/tests/functional/001_records/017_edit_post_primary_key.log new file mode 100644 index 0000000..f73d926 --- /dev/null +++ b/tests/functional/001_records/017_edit_post_primary_key.log @@ -0,0 +1,11 @@ +=== +PUT /records/posts/2 +Content-Type: application/json; charset=utf-8 + +{"id":1} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +0 diff --git a/tests/functional/001_records/018_add_post_missing_field.log b/tests/functional/001_records/018_add_post_missing_field.log new file mode 100644 index 0000000..bd6a012 --- /dev/null +++ b/tests/functional/001_records/018_add_post_missing_field.log @@ -0,0 +1,11 @@ +=== +POST /records/posts +Content-Type: application/json; charset=utf-8 + +{"category_id":1,"content":"test"} +=== +409 +Content-Type: application/json; charset=utf-8 +Content-Length: 50 + +{"code":1010,"message":"Data integrity violation"} diff --git a/tests/functional/001_records/019_list_with_paginate_in_multiple_order.log b/tests/functional/001_records/019_list_with_paginate_in_multiple_order.log new file mode 100644 index 0000000..ddd0e32 --- /dev/null +++ b/tests/functional/001_records/019_list_with_paginate_in_multiple_order.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?page=1,2&order=category_id,asc&order=id,desc +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 133 + +{"records":[{"id":14,"user_id":1,"category_id":1,"content":"#10"},{"id":13,"user_id":1,"category_id":1,"content":"#9"}],"results":12} diff --git a/tests/functional/001_records/020_list_with_paginate_in_descending_order.log b/tests/functional/001_records/020_list_with_paginate_in_descending_order.log new file mode 100644 index 0000000..b73c3a4 --- /dev/null +++ b/tests/functional/001_records/020_list_with_paginate_in_descending_order.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?page=2,2&order=id,desc +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 132 + +{"records":[{"id":12,"user_id":1,"category_id":1,"content":"#8"},{"id":11,"user_id":1,"category_id":1,"content":"#7"}],"results":12} diff --git a/tests/functional/001_records/021_list_with_size.log b/tests/functional/001_records/021_list_with_size.log new file mode 100644 index 0000000..0e2aa17 --- /dev/null +++ b/tests/functional/001_records/021_list_with_size.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?order=id&size=1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 75 + +{"records":[{"id":1,"user_id":1,"category_id":1,"content":"blog started"}]} diff --git a/tests/functional/001_records/022_list_with_zero_page_size.log b/tests/functional/001_records/022_list_with_zero_page_size.log new file mode 100644 index 0000000..3fbda30 --- /dev/null +++ b/tests/functional/001_records/022_list_with_zero_page_size.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?order=id&page=1,0 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"records":[],"results":12} diff --git a/tests/functional/001_records/023_list_with_zero_size.log b/tests/functional/001_records/023_list_with_zero_size.log new file mode 100644 index 0000000..a98a8bc --- /dev/null +++ b/tests/functional/001_records/023_list_with_zero_size.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?order=id&size=0 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 14 + +{"records":[]} diff --git a/tests/functional/001_records/024_list_with_paginate_last_page.log b/tests/functional/001_records/024_list_with_paginate_last_page.log new file mode 100644 index 0000000..78da836 --- /dev/null +++ b/tests/functional/001_records/024_list_with_paginate_last_page.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?page=3,5&order=id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 133 + +{"records":[{"id":13,"user_id":1,"category_id":1,"content":"#9"},{"id":14,"user_id":1,"category_id":1,"content":"#10"}],"results":12} diff --git a/tests/functional/001_records/025_list_example_from_readme_full_record.log b/tests/functional/001_records/025_list_example_from_readme_full_record.log new file mode 100644 index 0000000..9dc99ea --- /dev/null +++ b/tests/functional/001_records/025_list_example_from_readme_full_record.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?filter=id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 75 + +{"records":[{"id":1,"user_id":1,"category_id":1,"content":"blog started"}]} diff --git a/tests/functional/001_records/026_list_example_from_readme_with_exclude.log b/tests/functional/001_records/026_list_example_from_readme_with_exclude.log new file mode 100644 index 0000000..9bdbf5c --- /dev/null +++ b/tests/functional/001_records/026_list_example_from_readme_with_exclude.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?exclude=id&filter=id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 68 + +{"records":[{"user_id":1,"category_id":1,"content":"blog started"}]} diff --git a/tests/functional/001_records/027_list_example_from_readme_users_only.log b/tests/functional/001_records/027_list_example_from_readme_users_only.log new file mode 100644 index 0000000..22131d8 --- /dev/null +++ b/tests/functional/001_records/027_list_example_from_readme_users_only.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?join=users&filter=id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 136 + +{"records":[{"id":1,"user_id":{"id":1,"username":"user1","password":"pass1","location":null},"category_id":1,"content":"blog started"}]} diff --git a/tests/functional/001_records/028_read_example_from_readme_users_only.log b/tests/functional/001_records/028_read_example_from_readme_users_only.log new file mode 100644 index 0000000..7d59e18 --- /dev/null +++ b/tests/functional/001_records/028_read_example_from_readme_users_only.log @@ -0,0 +1,8 @@ +=== +GET /records/posts/1?join=users +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 122 + +{"id":1,"user_id":{"id":1,"username":"user1","password":"pass1","location":null},"category_id":1,"content":"blog started"} diff --git a/tests/functional/001_records/029_list_example_from_readme_comments_only.log b/tests/functional/001_records/029_list_example_from_readme_comments_only.log new file mode 100644 index 0000000..93b6ac8 --- /dev/null +++ b/tests/functional/001_records/029_list_example_from_readme_comments_only.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?join=comments&filter=id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 202 + +{"records":[{"id":1,"user_id":1,"category_id":1,"content":"blog started","comments":[{"id":1,"post_id":1,"message":"great","category_id":3},{"id":2,"post_id":1,"message":"fantastic","category_id":3}]}]} diff --git a/tests/functional/001_records/030_list_example_from_readme_tags_only.log b/tests/functional/001_records/030_list_example_from_readme_tags_only.log new file mode 100644 index 0000000..4c5a904 --- /dev/null +++ b/tests/functional/001_records/030_list_example_from_readme_tags_only.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?join=tags&filter=id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 177 + +{"records":[{"id":1,"user_id":1,"category_id":1,"content":"blog started","tags":[{"id":1,"name":"funny","is_important":false},{"id":2,"name":"important","is_important":true}]}]} diff --git a/tests/functional/001_records/031_list_example_from_readme_tags_with_join_path.log b/tests/functional/001_records/031_list_example_from_readme_tags_with_join_path.log new file mode 100644 index 0000000..0f89ee4 --- /dev/null +++ b/tests/functional/001_records/031_list_example_from_readme_tags_with_join_path.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?join=categories&join=post_tags,tags&join=comments&filter=id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 410 + +{"records":[{"id":1,"user_id":1,"category_id":{"id":1,"name":"announcement","icon":null},"content":"blog started","post_tags":[{"id":1,"post_id":1,"tag_id":{"id":1,"name":"funny","is_important":false}},{"id":2,"post_id":1,"tag_id":{"id":2,"name":"important","is_important":true}}],"comments":[{"id":1,"post_id":1,"message":"great","category_id":3},{"id":2,"post_id":1,"message":"fantastic","category_id":3}]}]} diff --git a/tests/functional/001_records/032_list_example_from_readme.log b/tests/functional/001_records/032_list_example_from_readme.log new file mode 100644 index 0000000..b921c16 --- /dev/null +++ b/tests/functional/001_records/032_list_example_from_readme.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?join=categories&join=tags&join=comments&filter=id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 345 + +{"records":[{"id":1,"user_id":1,"category_id":{"id":1,"name":"announcement","icon":null},"content":"blog started","tags":[{"id":1,"name":"funny","is_important":false},{"id":2,"name":"important","is_important":true}],"comments":[{"id":1,"post_id":1,"message":"great","category_id":3},{"id":2,"post_id":1,"message":"fantastic","category_id":3}]}]} diff --git a/tests/functional/001_records/033_list_example_from_readme_tag_name_only.log b/tests/functional/001_records/033_list_example_from_readme_tag_name_only.log new file mode 100644 index 0000000..53471d0 --- /dev/null +++ b/tests/functional/001_records/033_list_example_from_readme_tag_name_only.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?include=tags.name&join=categories&join=post_tags,tags&join=comments&filter=id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 198 + +{"records":[{"id":1,"category_id":{"id":1},"post_tags":[{"post_id":1,"tag_id":{"id":1,"name":"funny"}},{"post_id":1,"tag_id":{"id":2,"name":"important"}}],"comments":[{"post_id":1},{"post_id":1}]}]} diff --git a/tests/functional/001_records/034_list_example_from_readme_with_transform_with_exclude.log b/tests/functional/001_records/034_list_example_from_readme_with_transform_with_exclude.log new file mode 100644 index 0000000..5492c2f --- /dev/null +++ b/tests/functional/001_records/034_list_example_from_readme_with_transform_with_exclude.log @@ -0,0 +1,9 @@ +=== +GET /records/posts?join=categories&join=post_tags,tags&join=comments&exclude=comments.message,comments.category_id&filter=id,eq,1 + +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 338 + +{"records":[{"id":1,"user_id":1,"category_id":{"id":1,"name":"announcement","icon":null},"content":"blog started","post_tags":[{"id":1,"post_id":1,"tag_id":{"id":1,"name":"funny","is_important":false}},{"id":2,"post_id":1,"tag_id":{"id":2,"name":"important","is_important":true}}],"comments":[{"id":1,"post_id":1},{"id":2,"post_id":1}]}]} diff --git a/tests/functional/001_records/035_edit_category_with_binary_content.log b/tests/functional/001_records/035_edit_category_with_binary_content.log new file mode 100644 index 0000000..36b08ff --- /dev/null +++ b/tests/functional/001_records/035_edit_category_with_binary_content.log @@ -0,0 +1,18 @@ +=== +PUT /records/categories/2 + +{"icon":"4oKsIABhYmMACg1cYgA"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/categories/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 55 + +{"id":2,"name":"article","icon":"4oKsIABhYmMACg1cYgA="} diff --git a/tests/functional/001_records/036_edit_category_with_null.log b/tests/functional/001_records/036_edit_category_with_null.log new file mode 100644 index 0000000..cb9be22 --- /dev/null +++ b/tests/functional/001_records/036_edit_category_with_null.log @@ -0,0 +1,36 @@ +=== +PUT /records/categories/2 + +{"icon":""} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/categories/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 35 + +{"id":2,"name":"article","icon":""} +=== +PUT /records/categories/2 + +{"icon":null} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/categories/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 37 + +{"id":2,"name":"article","icon":null} diff --git a/tests/functional/001_records/037_edit_category_with_binary_content_with_post.log b/tests/functional/001_records/037_edit_category_with_binary_content_with_post.log new file mode 100644 index 0000000..62613bf --- /dev/null +++ b/tests/functional/001_records/037_edit_category_with_binary_content_with_post.log @@ -0,0 +1,19 @@ +=== +PUT /records/categories/2 +Content-Type: application/x-www-form-urlencoded + +icon=4oKsIABhYmMACg1cYgA +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/categories/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 55 + +{"id":2,"name":"article","icon":"4oKsIABhYmMACg1cYgA="} diff --git a/tests/functional/001_records/038_list_categories_with_binary_content.log b/tests/functional/001_records/038_list_categories_with_binary_content.log new file mode 100644 index 0000000..a746caa --- /dev/null +++ b/tests/functional/001_records/038_list_categories_with_binary_content.log @@ -0,0 +1,8 @@ +=== +GET /records/categories?filter=id,le,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 112 + +{"records":[{"id":1,"name":"announcement","icon":null},{"id":2,"name":"article","icon":"4oKsIABhYmMACg1cYgA="}]} diff --git a/tests/functional/001_records/039_edit_category_with_null_with_post.log b/tests/functional/001_records/039_edit_category_with_null_with_post.log new file mode 100644 index 0000000..b83a7ed --- /dev/null +++ b/tests/functional/001_records/039_edit_category_with_null_with_post.log @@ -0,0 +1,19 @@ +=== +PUT /records/categories/2 +Content-Type: application/x-www-form-urlencoded + +icon__is_null +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/categories/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 37 + +{"id":2,"name":"article","icon":null} diff --git a/tests/functional/001_records/040_add_post_failure.log b/tests/functional/001_records/040_add_post_failure.log new file mode 100644 index 0000000..fea7e20 --- /dev/null +++ b/tests/functional/001_records/040_add_post_failure.log @@ -0,0 +1,10 @@ +=== +POST /records/posts + +["truncat +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 50 + +{"code":1008,"message":"Cannot read HTTP message"} diff --git a/tests/functional/001_records/041_cors_pre_flight.log b/tests/functional/001_records/041_cors_pre_flight.log new file mode 100644 index 0000000..51a1da0 --- /dev/null +++ b/tests/functional/001_records/041_cors_pre_flight.log @@ -0,0 +1,12 @@ +=== +OPTIONS /records/posts/1?include=id +Origin: http://example.com +Access-Control-Request-Method: POST +Access-Control-Request-Headers: X-XSRF-TOKEN, X-Requested-With +=== +200 +Access-Control-Allow-Headers: Content-Type, X-XSRF-TOKEN, X-Authorization +Access-Control-Allow-Methods: OPTIONS, GET, PUT, POST, DELETE, PATCH +Access-Control-Max-Age: 1728000 +Access-Control-Allow-Credentials: true +Access-Control-Allow-Origin: http://example.com diff --git a/tests/functional/001_records/042_cors_headers.log b/tests/functional/001_records/042_cors_headers.log new file mode 100644 index 0000000..a4b2fbd --- /dev/null +++ b/tests/functional/001_records/042_cors_headers.log @@ -0,0 +1,11 @@ +=== +GET /records/posts/1?include=id +Origin: http://example.com +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 8 +Access-Control-Allow-Credentials: true +Access-Control-Allow-Origin: http://example.com + +{"id":1} diff --git a/tests/functional/001_records/043_error_on_invalid_json.log b/tests/functional/001_records/043_error_on_invalid_json.log new file mode 100644 index 0000000..7b2455c --- /dev/null +++ b/tests/functional/001_records/043_error_on_invalid_json.log @@ -0,0 +1,11 @@ +=== +POST /records/posts +Content-Type: application/json; charset=utf-8 + +{"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 50 + +{"code":1008,"message":"Cannot read HTTP message"} diff --git a/tests/functional/001_records/044_error_on_duplicate_unique_key.log b/tests/functional/001_records/044_error_on_duplicate_unique_key.log new file mode 100644 index 0000000..74c02c3 --- /dev/null +++ b/tests/functional/001_records/044_error_on_duplicate_unique_key.log @@ -0,0 +1,11 @@ +=== +POST /records/kunsthåndværk +Content-Type: application/json; charset=utf-8 + +{"id":"23587850-8738-437e-8c41-466627ca6094","Umlauts ä_ö_ü-COUNT":1} +=== +409 +Content-Type: application/json; charset=utf-8 +Content-Length: 49 + +{"code":1009,"message":"Duplicate key exception"} diff --git a/tests/functional/001_records/045_error_on_failing_foreign_key_constraint.log b/tests/functional/001_records/045_error_on_failing_foreign_key_constraint.log new file mode 100644 index 0000000..41ae900 --- /dev/null +++ b/tests/functional/001_records/045_error_on_failing_foreign_key_constraint.log @@ -0,0 +1,10 @@ +=== +POST /records/posts + +{"user_id":3,"category_id":1,"content":"fk constraint"} +=== +409 +Content-Type: application/json; charset=utf-8 +Content-Length: 50 + +{"code":1010,"message":"Data integrity violation"} diff --git a/tests/functional/001_records/046_error_on_non_existing_table.log b/tests/functional/001_records/046_error_on_non_existing_table.log new file mode 100644 index 0000000..4efa2cf --- /dev/null +++ b/tests/functional/001_records/046_error_on_non_existing_table.log @@ -0,0 +1,8 @@ +=== +GET /records/postzzz +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 51 + +{"code":1001,"message":"Table 'postzzz' not found"} diff --git a/tests/functional/001_records/047_error_on_invalid_path.log b/tests/functional/001_records/047_error_on_invalid_path.log new file mode 100644 index 0000000..c98935f --- /dev/null +++ b/tests/functional/001_records/047_error_on_invalid_path.log @@ -0,0 +1,8 @@ +=== +GET /postzzz +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 53 + +{"code":1000,"message":"Route '\/postzzz' not found"} diff --git a/tests/functional/001_records/048_error_on_invalid_argument_count.log b/tests/functional/001_records/048_error_on_invalid_argument_count.log new file mode 100644 index 0000000..fbf24e4 --- /dev/null +++ b/tests/functional/001_records/048_error_on_invalid_argument_count.log @@ -0,0 +1,10 @@ +=== +PUT /records/posts/1,2 + +{"id":1,"user_id":1,"category_id":1,"content":"blog started"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 58 + +{"code":1002,"message":"Argument count mismatch in '1,2'"} diff --git a/tests/functional/001_records/049_error_on_invalid_argument_count.log b/tests/functional/001_records/049_error_on_invalid_argument_count.log new file mode 100644 index 0000000..da45f21 --- /dev/null +++ b/tests/functional/001_records/049_error_on_invalid_argument_count.log @@ -0,0 +1,10 @@ +=== +PUT /records/posts/1,2 + +[{"id":1,"user_id":1,"category_id":1,"content":"blog started"}] +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 58 + +{"code":1002,"message":"Argument count mismatch in '1,2'"} diff --git a/tests/functional/001_records/050_no_error_on_argument_count_one.log b/tests/functional/001_records/050_no_error_on_argument_count_one.log new file mode 100644 index 0000000..557db22 --- /dev/null +++ b/tests/functional/001_records/050_no_error_on_argument_count_one.log @@ -0,0 +1,10 @@ +=== +PUT /records/posts/1 + +[{"id":1,"user_id":1,"category_id":1,"content":"blog started"}] +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 3 + +[1] diff --git a/tests/functional/001_records/051_error_on_invalid_argument_count.log b/tests/functional/001_records/051_error_on_invalid_argument_count.log new file mode 100644 index 0000000..925b3cc --- /dev/null +++ b/tests/functional/001_records/051_error_on_invalid_argument_count.log @@ -0,0 +1,10 @@ +=== +PUT /records/posts/1 + +[{"id":1},{"id":2}] +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 56 + +{"code":1002,"message":"Argument count mismatch in '1'"} diff --git a/tests/functional/001_records/052_edit_user_location.log b/tests/functional/001_records/052_edit_user_location.log new file mode 100644 index 0000000..a8565eb --- /dev/null +++ b/tests/functional/001_records/052_edit_user_location.log @@ -0,0 +1,18 @@ +=== +PUT /records/users/1 + +{"location":"POINT(30 20)"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/users/1?include=id,location +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 34 + +{"id":1,"location":"POINT(30 20)"} diff --git a/tests/functional/001_records/053_list_user_locations.log b/tests/functional/001_records/053_list_user_locations.log new file mode 100644 index 0000000..591f5d7 --- /dev/null +++ b/tests/functional/001_records/053_list_user_locations.log @@ -0,0 +1,8 @@ +=== +GET /records/users?include=id,location +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 73 + +{"records":[{"id":1,"location":"POINT(30 20)"},{"id":2,"location":null}]} diff --git a/tests/functional/001_records/054_edit_user_with_id.log b/tests/functional/001_records/054_edit_user_with_id.log new file mode 100644 index 0000000..3d07972 --- /dev/null +++ b/tests/functional/001_records/054_edit_user_with_id.log @@ -0,0 +1,18 @@ +=== +PUT /records/users/1 + +{"id":2,"password":"testtest2"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/users/1?include=id,username,password +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 50 + +{"id":1,"username":"user1","password":"testtest2"} diff --git a/tests/functional/001_records/055_filter_category_on_null_icon.log b/tests/functional/001_records/055_filter_category_on_null_icon.log new file mode 100644 index 0000000..78d3c54 --- /dev/null +++ b/tests/functional/001_records/055_filter_category_on_null_icon.log @@ -0,0 +1,16 @@ +=== +GET /records/categories?filter=icon,is,null&filter=id,le,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 94 + +{"records":[{"id":1,"name":"announcement","icon":null},{"id":2,"name":"article","icon":null}]} +=== +GET /records/categories?filter=icon,is&filter=id,le,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 94 + +{"records":[{"id":1,"name":"announcement","icon":null},{"id":2,"name":"article","icon":null}]} diff --git a/tests/functional/001_records/056_filter_category_on_not_null_icon.log b/tests/functional/001_records/056_filter_category_on_not_null_icon.log new file mode 100644 index 0000000..617dbd4 --- /dev/null +++ b/tests/functional/001_records/056_filter_category_on_not_null_icon.log @@ -0,0 +1,8 @@ +=== +GET /records/categories?filter=icon,nis,null +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 14 + +{"records":[]} diff --git a/tests/functional/001_records/057_filter_on_and.log b/tests/functional/001_records/057_filter_on_and.log new file mode 100644 index 0000000..f593dea --- /dev/null +++ b/tests/functional/001_records/057_filter_on_and.log @@ -0,0 +1,24 @@ +=== +GET /records/posts?include=id&filter=id,ge,1&filter=id,le,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 31 + +{"records":[{"id":1},{"id":2}]} +=== +GET /records/posts?include=id&filter[]=id,ge,1&filter[]=id,le,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 31 + +{"records":[{"id":1},{"id":2}]} +=== +GET /records/posts?include=id&filter[0]=id,ge,1&filter[1]=id,le,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 31 + +{"records":[{"id":1},{"id":2}]} diff --git a/tests/functional/001_records/058_filter_on_or.log b/tests/functional/001_records/058_filter_on_or.log new file mode 100644 index 0000000..a459a21 --- /dev/null +++ b/tests/functional/001_records/058_filter_on_or.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?include=id&filter1=id,eq,1&filter2=id,eq,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 31 + +{"records":[{"id":1},{"id":2}]} diff --git a/tests/functional/001_records/059_filter_on_and_plus_or.log b/tests/functional/001_records/059_filter_on_and_plus_or.log new file mode 100644 index 0000000..b4a8afa --- /dev/null +++ b/tests/functional/001_records/059_filter_on_and_plus_or.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?include=id&filter1=id,eq,1&filter2=id,gt,1&filter2=id,lt,3 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 31 + +{"records":[{"id":1},{"id":2}]} diff --git a/tests/functional/001_records/060_filter_on_or_plus_and.log b/tests/functional/001_records/060_filter_on_or_plus_and.log new file mode 100644 index 0000000..29657b2 --- /dev/null +++ b/tests/functional/001_records/060_filter_on_or_plus_and.log @@ -0,0 +1,8 @@ +=== +GET /records/posts?include=id&filter1=id,eq,1&filter2=id,eq,2&filter=user_id,eq,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 31 + +{"records":[{"id":1},{"id":2}]} diff --git a/tests/functional/001_records/061_get_post_content_with_included_tag_names.log b/tests/functional/001_records/061_get_post_content_with_included_tag_names.log new file mode 100644 index 0000000..6ac8225 --- /dev/null +++ b/tests/functional/001_records/061_get_post_content_with_included_tag_names.log @@ -0,0 +1,8 @@ +=== +GET /records/posts/1?include=content,tags.name&join=tags +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 94 + +{"id":1,"content":"blog started","tags":[{"id":1,"name":"funny"},{"id":2,"name":"important"}]} diff --git a/tests/functional/001_records/062_read_kunsthandvaerk.log b/tests/functional/001_records/062_read_kunsthandvaerk.log new file mode 100644 index 0000000..91a4b64 --- /dev/null +++ b/tests/functional/001_records/062_read_kunsthandvaerk.log @@ -0,0 +1,16 @@ +=== +GET /records/kunsthåndværk/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 138 + +{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d","Umlauts ä_ö_ü-COUNT":1,"user_id":1,"invisible_id":"e42c77c6-06a4-4502-816c-d112c7142e6d"} +=== +GET /records/kunsthåndværk/e42c77c6-06a4-4502-816c-d112c7142e6d?join=invisibles +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 138 + +{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d","Umlauts ä_ö_ü-COUNT":1,"user_id":1,"invisible_id":"e42c77c6-06a4-4502-816c-d112c7142e6d"} diff --git a/tests/functional/001_records/063_list_kunsthandvaerk.log b/tests/functional/001_records/063_list_kunsthandvaerk.log new file mode 100644 index 0000000..83a25ca --- /dev/null +++ b/tests/functional/001_records/063_list_kunsthandvaerk.log @@ -0,0 +1,8 @@ +=== +GET /records/kunsthåndværk +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 152 + +{"records":[{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d","Umlauts ä_ö_ü-COUNT":1,"user_id":1,"invisible_id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}]} diff --git a/tests/functional/001_records/064_add_kunsthandvaerk.log b/tests/functional/001_records/064_add_kunsthandvaerk.log new file mode 100644 index 0000000..6ea4a7d --- /dev/null +++ b/tests/functional/001_records/064_add_kunsthandvaerk.log @@ -0,0 +1,10 @@ +=== +POST /records/kunsthåndværk + +{"id":"34451583-a747-4417-bdf0-bec7a5eacffa","Umlauts ä_ö_ü-COUNT":3} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 38 + +"34451583-a747-4417-bdf0-bec7a5eacffa" diff --git a/tests/functional/001_records/065_edit_kunsthandvaerk.log b/tests/functional/001_records/065_edit_kunsthandvaerk.log new file mode 100644 index 0000000..086b4c3 --- /dev/null +++ b/tests/functional/001_records/065_edit_kunsthandvaerk.log @@ -0,0 +1,10 @@ +=== +PUT /records/kunsthåndværk/34451583-a747-4417-bdf0-bec7a5eacffa + +{"Umlauts ä_ö_ü-COUNT":3} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 diff --git a/tests/functional/001_records/066_delete_kunsthandvaerk.log b/tests/functional/001_records/066_delete_kunsthandvaerk.log new file mode 100644 index 0000000..8c44b92 --- /dev/null +++ b/tests/functional/001_records/066_delete_kunsthandvaerk.log @@ -0,0 +1,8 @@ +=== +DELETE /records/kunsthåndværk/34451583-a747-4417-bdf0-bec7a5eacffa +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 diff --git a/tests/functional/001_records/067_edit_comment_with_validation.log b/tests/functional/001_records/067_edit_comment_with_validation.log new file mode 100644 index 0000000..aedcdb8 --- /dev/null +++ b/tests/functional/001_records/067_edit_comment_with_validation.log @@ -0,0 +1,10 @@ +=== +PUT /records/comments/4 + +{"post_id":"two"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 104 + +{"code":1013,"message":"Input validation failed for 'comments'","details":{"post_id":"must be numeric"}} diff --git a/tests/functional/001_records/068_add_comment_with_sanitation.log b/tests/functional/001_records/068_add_comment_with_sanitation.log new file mode 100644 index 0000000..eec3909 --- /dev/null +++ b/tests/functional/001_records/068_add_comment_with_sanitation.log @@ -0,0 +1,18 @@ +=== +POST /records/comments + +{"user_id":1,"post_id":2,"message":"

    Title

    Body

    ","category_id":3} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +5 +=== +GET /records/comments/5 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 59 + +{"id":5,"post_id":2,"message":"Title Body","category_id":3} diff --git a/tests/functional/001_records/069_increment_event_visitors.log b/tests/functional/001_records/069_increment_event_visitors.log new file mode 100644 index 0000000..977aea9 --- /dev/null +++ b/tests/functional/001_records/069_increment_event_visitors.log @@ -0,0 +1,64 @@ +=== +GET /records/events/1?include=visitors +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 14 + +{"visitors":0} +=== +PATCH /records/events/1 + +{"visitors":1} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PATCH /records/events/1 + +{"visitors":1} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PATCH /records/events/1,1 + +[{"visitors":1},{"visitors":1}] +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 5 + +[1,1] +=== +GET /records/events/1?include=visitors +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 14 + +{"visitors":4} +=== +PATCH /records/events/1 + +{"visitors":-4} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/events/1?include=visitors +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 14 + +{"visitors":0} diff --git a/tests/functional/001_records/070_list_invisibles.log b/tests/functional/001_records/070_list_invisibles.log new file mode 100644 index 0000000..1a7723a --- /dev/null +++ b/tests/functional/001_records/070_list_invisibles.log @@ -0,0 +1,8 @@ +=== +GET /records/invisibles +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"code":1001,"message":"Table 'invisibles' not found"} diff --git a/tests/functional/001_records/071_add_comment_with_invisible_record.log b/tests/functional/001_records/071_add_comment_with_invisible_record.log new file mode 100644 index 0000000..a60502a --- /dev/null +++ b/tests/functional/001_records/071_add_comment_with_invisible_record.log @@ -0,0 +1,18 @@ +=== +POST /records/comments + +{"user_id":1,"post_id":2,"message":"invisible","category_id":3} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +6 +=== +GET /records/comments/6 +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 46 + +{"code":1003,"message":"Record '6' not found"} diff --git a/tests/functional/001_records/072_list_nopk.log b/tests/functional/001_records/072_list_nopk.log new file mode 100644 index 0000000..fcce9a5 --- /dev/null +++ b/tests/functional/001_records/072_list_nopk.log @@ -0,0 +1,8 @@ +=== +GET /records/nopk +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 59 + +{"records":[{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}]} diff --git a/tests/functional/001_records/073_multi_tenancy_kunsthandvaerk.log b/tests/functional/001_records/073_multi_tenancy_kunsthandvaerk.log new file mode 100644 index 0000000..d8c124e --- /dev/null +++ b/tests/functional/001_records/073_multi_tenancy_kunsthandvaerk.log @@ -0,0 +1,52 @@ +=== +POST /records/kunsthåndværk + +{"id":"b55decba-8eb5-436b-af3e-148f7b4eacda","Umlauts ä_ö_ü-COUNT":4,"user_id":2} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 38 + +"b55decba-8eb5-436b-af3e-148f7b4eacda" +=== +GET /records/kunsthåndværk/b55decba-8eb5-436b-af3e-148f7b4eacda +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 104 + +{"id":"b55decba-8eb5-436b-af3e-148f7b4eacda","Umlauts ä_ö_ü-COUNT":4,"user_id":1,"invisible_id":null} +=== +PUT /records/kunsthåndværk/b55decba-8eb5-436b-af3e-148f7b4eacda + +{"id":"b55decba-8eb5-436b-af3e-148f7b4eacda","Umlauts ä_ö_ü-COUNT":4,"user_id":2} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/kunsthåndværk/b55decba-8eb5-436b-af3e-148f7b4eacda +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 104 + +{"id":"b55decba-8eb5-436b-af3e-148f7b4eacda","Umlauts ä_ö_ü-COUNT":4,"user_id":1,"invisible_id":null} +=== +DELETE /records/kunsthåndværk/e31ecfe6-591f-4660-9fbd-1a232083037f +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +0 +=== +DELETE /records/kunsthåndværk/b55decba-8eb5-436b-af3e-148f7b4eacda +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 diff --git a/tests/functional/001_records/074_custom_kunsthandvaerk.log b/tests/functional/001_records/074_custom_kunsthandvaerk.log new file mode 100644 index 0000000..ee47c23 --- /dev/null +++ b/tests/functional/001_records/074_custom_kunsthandvaerk.log @@ -0,0 +1,22 @@ +=== +PATCH /records/kunsthåndværk/e42c77c6-06a4-4502-816c-d112c7142e6d + +{"Umlauts ä_ö_ü-COUNT":10} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 +X-Time-Taken: 0.003 + +1 +=== +PATCH /records/kunsthåndværk/e42c77c6-06a4-4502-816c-d112c7142e6d + +{"Umlauts ä_ö_ü-COUNT":-10} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 +X-Time-Taken: 0.003 + +1 diff --git a/tests/functional/001_records/075_list_tag_usage.log b/tests/functional/001_records/075_list_tag_usage.log new file mode 100644 index 0000000..a965ed3 --- /dev/null +++ b/tests/functional/001_records/075_list_tag_usage.log @@ -0,0 +1,24 @@ +=== +GET /records/tag_usage +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 85 + +{"records":[{"id":1,"name":"funny","count":2},{"id":2,"name":"important","count":2}]} +=== +GET /records/tag_usage/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 33 + +{"id":1,"name":"funny","count":2} +=== +DELETE /records/tag_usage/1 +=== +405 +Content-Type: application/json; charset=utf-8 +Content-Length: 58 + +{"code":1015,"message":"Operation 'delete' not supported"} diff --git a/tests/functional/001_records/076_list_user_locations_within_geometry.log b/tests/functional/001_records/076_list_user_locations_within_geometry.log new file mode 100644 index 0000000..dbd1f5e --- /dev/null +++ b/tests/functional/001_records/076_list_user_locations_within_geometry.log @@ -0,0 +1,9 @@ +skip-for-sqlite: no support for geometry functions (spatialite) +=== +GET /records/users?include=id,location&filter=location,swi,POLYGON((10 10,10 50,50 50,50 10,10 10)) +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 48 + +{"records":[{"id":1,"location":"POINT(30 20)"}]} diff --git a/tests/functional/001_records/077_list_posts_with_page_limits.log b/tests/functional/001_records/077_list_posts_with_page_limits.log new file mode 100644 index 0000000..5c9925f --- /dev/null +++ b/tests/functional/001_records/077_list_posts_with_page_limits.log @@ -0,0 +1,32 @@ +=== +GET /records/posts?size=10 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 690 + +{"records":[{"id":1,"user_id":1,"category_id":1,"content":"blog started"},{"id":2,"user_id":1,"category_id":2,"content":"🦀€ Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте"},{"id":5,"user_id":1,"category_id":1,"content":"#1"},{"id":6,"user_id":1,"category_id":1,"content":"#2"},{"id":7,"user_id":1,"category_id":1,"content":"#3"},{"id":8,"user_id":1,"category_id":1,"content":"#4"},{"id":9,"user_id":1,"category_id":1,"content":"#5"},{"id":10,"user_id":1,"category_id":1,"content":"#6"},{"id":11,"user_id":1,"category_id":1,"content":"#7"},{"id":12,"user_id":1,"category_id":1,"content":"#8"}]} +=== +GET /records/posts +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 690 + +{"records":[{"id":1,"user_id":1,"category_id":1,"content":"blog started"},{"id":2,"user_id":1,"category_id":2,"content":"🦀€ Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте"},{"id":5,"user_id":1,"category_id":1,"content":"#1"},{"id":6,"user_id":1,"category_id":1,"content":"#2"},{"id":7,"user_id":1,"category_id":1,"content":"#3"},{"id":8,"user_id":1,"category_id":1,"content":"#4"},{"id":9,"user_id":1,"category_id":1,"content":"#5"},{"id":10,"user_id":1,"category_id":1,"content":"#6"},{"id":11,"user_id":1,"category_id":1,"content":"#7"},{"id":12,"user_id":1,"category_id":1,"content":"#8"}]} +=== +GET /records/posts?page=5,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 78 + +{"records":[{"id":7,"user_id":1,"category_id":1,"content":"#3"}],"results":12} +=== +GET /records/posts?page=6,1 +=== +403 +Content-Type: application/json; charset=utf-8 +Content-Length: 46 + +{"code":1019,"message":"Pagination forbidden"} diff --git a/tests/functional/001_records/078_edit_event_with_nullable_bigint.log b/tests/functional/001_records/078_edit_event_with_nullable_bigint.log new file mode 100644 index 0000000..a6c9810 --- /dev/null +++ b/tests/functional/001_records/078_edit_event_with_nullable_bigint.log @@ -0,0 +1,44 @@ +=== +GET /records/events/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"id":1,"name":"Launch","datetime":"2016-01-01 13:01:01","visitors":0} +=== +PUT /records/events/1 + +{"datetime":null,"visitors":null} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/events/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 56 + +{"id":1,"name":"Launch","datetime":null,"visitors":null} +=== +PUT /records/events/1 + +{"datetime":"2016-01-01 13:01:01","visitors":0} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/events/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"id":1,"name":"Launch","datetime":"2016-01-01 13:01:01","visitors":0} diff --git a/tests/functional/001_records/079_read_post_with_categories.log b/tests/functional/001_records/079_read_post_with_categories.log new file mode 100644 index 0000000..949c975 --- /dev/null +++ b/tests/functional/001_records/079_read_post_with_categories.log @@ -0,0 +1,32 @@ +=== +GET /records/posts/1?join=tags&include=tags.name +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 69 + +{"id":1,"tags":[{"id":1,"name":"funny"},{"id":2,"name":"important"}]} +=== +GET /records/posts/1?join=categories&include=category_id,categories.name +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 53 + +{"id":1,"category_id":{"id":1,"name":"announcement"}} +=== +GET /records/posts/1?join=comments,categories&include=category_id,categories.name +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 145 + +{"id":1,"category_id":1,"comments":[{"post_id":1,"category_id":{"id":3,"name":"comment"}},{"post_id":1,"category_id":{"id":3,"name":"comment"}}]} +=== +GET /records/posts/1?join=categories&join=comments,categories&include=category_id,categories.name +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 174 + +{"id":1,"category_id":{"id":1,"name":"announcement"},"comments":[{"post_id":1,"category_id":{"id":3,"name":"comment"}},{"post_id":1,"category_id":{"id":3,"name":"comment"}}]} diff --git a/tests/functional/001_records/080_add_barcode_with_ip_address.log b/tests/functional/001_records/080_add_barcode_with_ip_address.log new file mode 100644 index 0000000..0903506 --- /dev/null +++ b/tests/functional/001_records/080_add_barcode_with_ip_address.log @@ -0,0 +1,44 @@ +=== +POST /records/barcodes + +{"product_id":1,"hex":"","bin":""} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +2 +=== +GET /records/barcodes/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 64 + +{"id":2,"product_id":1,"hex":"","bin":"","ip_address":"TEST_IP"} +=== +PUT /records/barcodes/2 + +{"ip_address":"FAKE_IP"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +0 +=== +GET /records/barcodes/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 64 + +{"id":2,"product_id":1,"hex":"","bin":"","ip_address":"TEST_IP"} +=== +DELETE /records/barcodes/2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 diff --git a/tests/functional/001_records/081_read_countries_as_geojson.log b/tests/functional/001_records/081_read_countries_as_geojson.log new file mode 100644 index 0000000..19aa154 --- /dev/null +++ b/tests/functional/001_records/081_read_countries_as_geojson.log @@ -0,0 +1,73 @@ +skip-for-sqlite: no support for geometry functions (spatialite) +=== +GET /geojson/countries/3 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 105 + +{"type":"Feature","id":3,"properties":{"name":"Point"},"geometry":{"type":"Point","coordinates":[30,10]}} +=== +GET /geojson/countries/4 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 127 + +{"type":"Feature","id":4,"properties":{"name":"Line"},"geometry":{"type":"LineString","coordinates":[[30,10],[10,30],[40,40]]}} +=== +GET /geojson/countries/5 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 143 + +{"type":"Feature","id":5,"properties":{"name":"Poly1"},"geometry":{"type":"Polygon","coordinates":[[[30,10],[40,40],[20,40],[10,20],[30,10]]]}} +=== +GET /geojson/countries/6 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 177 + +{"type":"Feature","id":6,"properties":{"name":"Poly2"},"geometry":{"type":"Polygon","coordinates":[[[35,10],[45,45],[15,40],[10,20],[35,10]],[[20,30],[35,35],[30,20],[20,30]]]}} +=== +GET /geojson/countries/7 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 137 + +{"type":"Feature","id":7,"properties":{"name":"Mpoint"},"geometry":{"type":"MultiPoint","coordinates":[[10,40],[40,30],[20,20],[30,10]]}} +=== +GET /geojson/countries/8 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 169 + +{"type":"Feature","id":8,"properties":{"name":"Mline"},"geometry":{"type":"MultiLineString","coordinates":[[[10,10],[20,20],[10,40]],[[40,40],[30,30],[40,20],[30,10]]]}} +=== +GET /geojson/countries/9 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 184 + +{"type":"Feature","id":9,"properties":{"name":"Mpoly1"},"geometry":{"type":"MultiPolygon","coordinates":[[[[30,20],[45,40],[10,40],[30,20]]],[[[15,5],[40,10],[10,20],[5,10],[15,5]]]]}} +=== +GET /geojson/countries/10 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 229 + +{"type":"Feature","id":10,"properties":{"name":"Mpoly2"},"geometry":{"type":"MultiPolygon","coordinates":[[[[40,40],[20,45],[45,30],[40,40]]],[[[20,35],[10,30],[10,10],[30,5],[45,20],[20,35]],[[30,20],[20,15],[20,25],[30,20]]]]}} +=== +GET /geojson/countries/11 +=== +500 +Content-Type: application/json; charset=utf-8 +Content-Length: 73 + +{"code":9999,"message":"Geometry type not supported: GEOMETRYCOLLECTION"} diff --git a/tests/functional/001_records/082_read_users_as_geojson.log b/tests/functional/001_records/082_read_users_as_geojson.log new file mode 100644 index 0000000..434e636 --- /dev/null +++ b/tests/functional/001_records/082_read_users_as_geojson.log @@ -0,0 +1,17 @@ +skip-for-sqlite: no support for geometry functions (spatialite) +=== +GET /geojson/users/1?exclude=password +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 109 + +{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}} +=== +GET /geojson/users/2?exclude=password +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 75 + +{"type":"Feature","id":2,"properties":{"username":"user2"},"geometry":null} diff --git a/tests/functional/001_records/083_list_users_as_geojson.log b/tests/functional/001_records/083_list_users_as_geojson.log new file mode 100644 index 0000000..d63d3e7 --- /dev/null +++ b/tests/functional/001_records/083_list_users_as_geojson.log @@ -0,0 +1,41 @@ +skip-for-sqlite: no support for geometry functions (spatialite) +=== +GET /geojson/users?exclude=password +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 227 + +{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}},{"type":"Feature","id":2,"properties":{"username":"user2"},"geometry":null}]} +=== +GET /geojson/users?exclude=password&geometry=location +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 227 + +{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}},{"type":"Feature","id":2,"properties":{"username":"user2"},"geometry":null}]} +=== +GET /geojson/users?exclude=password&geometry=notlocation +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 235 + +{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1","location":"POINT(30 20)"},"geometry":null},{"type":"Feature","id":2,"properties":{"username":"user2","location":null},"geometry":null}]} +=== +GET /geojson/users?exclude=password&page=1,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 163 + +{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}}],"results":2} +=== +GET /geojson/users?exclude=password&bbox=29.99,19.99,30.01,20.01 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 151 + +{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}}]} diff --git a/tests/functional/001_records/084_update_tags_with_boolean.log b/tests/functional/001_records/084_update_tags_with_boolean.log new file mode 100644 index 0000000..17bb2d9 --- /dev/null +++ b/tests/functional/001_records/084_update_tags_with_boolean.log @@ -0,0 +1,44 @@ +=== +GET /records/tags/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 44 + +{"id":1,"name":"funny","is_important":false} +=== +PUT /records/tags/1 + +{"id":1,"name":"funny","is_important":true} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/tags/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 43 + +{"id":1,"name":"funny","is_important":true} +=== +PUT /records/tags/1 + +{"id":1,"name":"funny","is_important":false} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/tags/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 44 + +{"id":1,"name":"funny","is_important":false} diff --git a/tests/functional/001_records/085_update_invisble_column_kunsthandvaerk.log b/tests/functional/001_records/085_update_invisble_column_kunsthandvaerk.log new file mode 100644 index 0000000..be64cee --- /dev/null +++ b/tests/functional/001_records/085_update_invisble_column_kunsthandvaerk.log @@ -0,0 +1,44 @@ +=== +POST /records/kunsthåndværk + +{"id":"b55decba-8eb5-436b-af3e-148f7b4eacda","Umlauts ä_ö_ü-COUNT":4,"user_id":2} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 38 + +"b55decba-8eb5-436b-af3e-148f7b4eacda" +=== +GET /records/kunsthåndværk/b55decba-8eb5-436b-af3e-148f7b4eacda +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 104 + +{"id":"b55decba-8eb5-436b-af3e-148f7b4eacda","Umlauts ä_ö_ü-COUNT":4,"user_id":1,"invisible_id":null} +=== +PUT /records/kunsthåndværk/b55decba-8eb5-436b-af3e-148f7b4eacda + +{"id":"b55decba-8eb5-436b-af3e-148f7b4eacda","Umlauts ä_ö_ü-COUNT":3,"invisible":"test"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/kunsthåndværk/b55decba-8eb5-436b-af3e-148f7b4eacda +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 104 + +{"id":"b55decba-8eb5-436b-af3e-148f7b4eacda","Umlauts ä_ö_ü-COUNT":3,"user_id":1,"invisible_id":null} +=== +DELETE /records/kunsthåndværk/b55decba-8eb5-436b-af3e-148f7b4eacda +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 diff --git a/tests/functional/001_records/086_add_and_update_posts_in_large_batch.log b/tests/functional/001_records/086_add_and_update_posts_in_large_batch.log new file mode 100644 index 0000000..e71f449 --- /dev/null +++ b/tests/functional/001_records/086_add_and_update_posts_in_large_batch.log @@ -0,0 +1,23 @@ +skip-always: test to expensive to run automatic, should be enabled by commenting out this line +=== +POST /records/posts +Content-Type: application/json; charset=utf-8 + +[{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"},{"user_id":1,"category_id":1,"content":"test"}] +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 5963 + +[16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094,1095,1096,1097,1098,1099,1100,1101,1102,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1133,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1163,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,1187,1188,1189,1190,1191,1192,1193,1194,1195,1196,1197,1198,1199,1200,1201,1202,1203,1204,1205,1206,1207,1208,1209,1210,1211,1212,1213,1214,1215,1216,1217,1218,1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231,1232,1233,1234,1235,1236,1237,1238,1239,1240,1241,1242,1243,1244,1245,1246,1247,1248,1249,1250,1251,1252,1253,1254,1255,1256,1257,1258,1259,1260,1261,1262,1263,1264,1265,1266,1267,1268,1269,1270,1271,1272,1273,1274,1275,1276,1277,1278,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291,1292,1293,1294,1295,1296,1297,1298,1299,1300,1301,1302,1303,1304,1305,1306,1307,1308,1309,1310,1311,1312,1313,1314,1315,1316,1317,1318,1319,1320,1321,1322,1323,1324,1325,1326,1327,1328,1329,1330,1331,1332,1333,1334,1335,1336,1337,1338,1339,1340,1341,1342,1343,1344,1345,1346,1347,1348,1349,1350,1351,1352,1353,1354,1355,1356,1357,1358,1359,1360,1361,1362,1363,1364,1365,1366,1367,1368,1369,1370,1371,1372,1373,1374,1375,1376,1377,1378,1379,1380,1381,1382,1383,1384,1385,1386,1387,1388,1389,1390,1391,1392,1393,1394,1395,1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406,1407,1408,1409,1410,1411,1412,1413,1414,1415,1416,1417,1418,1419,1420,1421] +=== +PUT /records/posts/16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094,1095,1096,1097,1098,1099,1100,1101,1102,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1133,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1163,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,1187,1188,1189,1190,1191,1192,1193,1194,1195,1196,1197,1198,1199,1200,1201,1202,1203,1204,1205,1206,1207,1208,1209,1210,1211,1212,1213,1214,1215,1216,1217,1218,1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231,1232,1233,1234,1235,1236,1237,1238,1239,1240,1241,1242,1243,1244,1245,1246,1247,1248,1249,1250,1251,1252,1253,1254,1255,1256,1257,1258,1259,1260,1261,1262,1263,1264,1265,1266,1267,1268,1269,1270,1271,1272,1273,1274,1275,1276,1277,1278,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291,1292,1293,1294,1295,1296,1297,1298,1299,1300,1301,1302,1303,1304,1305,1306,1307,1308,1309,1310,1311,1312,1313,1314,1315,1316,1317,1318,1319,1320,1321,1322,1323,1324,1325,1326,1327,1328,1329,1330,1331,1332,1333,1334,1335,1336,1337,1338,1339,1340,1341,1342,1343,1344,1345,1346,1347,1348,1349,1350,1351,1352,1353,1354,1355,1356,1357,1358,1359,1360,1361,1362,1363,1364,1365,1366,1367,1368,1369,1370,1371,1372,1373,1374,1375,1376,1377,1378,1379,1380,1381,1382,1383,1384,1385,1386,1387,1388,1389,1390,1391,1392,1393,1394,1395,1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406,1407,1408,1409,1410,1411,1412,1413,1414,1415,1416,1417,1418,1419,1420,1421 +Content-Type: application/json; charset=utf-8 + +[{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"},{"user_id":2,"category_id":1,"content":"test"}] +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 2813 + +[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] diff --git a/tests/functional/001_records/087_read_and_write_posts_as_xml.log b/tests/functional/001_records/087_read_and_write_posts_as_xml.log new file mode 100644 index 0000000..f5e9271 --- /dev/null +++ b/tests/functional/001_records/087_read_and_write_posts_as_xml.log @@ -0,0 +1,116 @@ +=== +GET /records/posts/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 61 + +{"id":1,"user_id":1,"category_id":1,"content":"blog started"} +=== +GET /records/posts/1?format=xml +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 102 + +111blog started +=== +GET /records/posts?size=1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 75 + +{"records":[{"id":1,"user_id":1,"category_id":1,"content":"blog started"}]} +=== +GET /records/posts?size=1&format=xml +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 147 + +111blog started +=== +GET /records/posts/1?join=users&format=xml +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 200 + +11user1testtest2POINT(30 20)1blog started +=== +GET /records/posts/1?join=users&join=comments,categories +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 335 + +{"id":1,"user_id":{"id":1,"username":"user1","password":"testtest2","location":"POINT(30 20)"},"category_id":1,"content":"blog started","comments":[{"id":1,"post_id":1,"message":"great","category_id":{"id":3,"name":"comment","icon":null}},{"id":2,"post_id":1,"message":"fantastic","category_id":{"id":3,"name":"comment","icon":null}}]} +=== +GET /records/posts/1?join=users&join=comments,categories&format=xml +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 536 + +11user1testtest2POINT(30 20)1blog started11great3comment21fantastic3comment +=== +GET /records/posts?page=2,1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 222 + +{"records":[{"id":2,"user_id":1,"category_id":2,"content":"🦀€ Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте"}],"results":12} +=== +GET /records/posts?page=2,1&format=xml +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 302 + +212🦀€ Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте12 +=== +POST /records/posts?format=xml +Content-Type: application/xml + +111blog started +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 72 + +1009Duplicate key exception +=== +PUT /records/posts/1?format=xml +Content-Type: application/xml + +11blog started +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 14 + +1 +=== +PUT /records/posts/1?format=xml +Content-Type: application/xml + +a +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 137 + +1013Input validation failed for 'posts'
    invalid integer
    +=== +PUT /records/posts/1?format=xml +Content-Type: application/xml + +a +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 73 + +1008Cannot read HTTP message diff --git a/tests/functional/001_records/088_read_and_write_multiple_posts_as_xml.log b/tests/functional/001_records/088_read_and_write_multiple_posts_as_xml.log new file mode 100644 index 0000000..269f2cb --- /dev/null +++ b/tests/functional/001_records/088_read_and_write_multiple_posts_as_xml.log @@ -0,0 +1,26 @@ +=== +GET /records/posts/1,2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 259 + +[{"id":1,"user_id":1,"category_id":1,"content":"blog started"},{"id":2,"user_id":1,"category_id":2,"content":"🦀€ Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте"}] +=== +GET /records/posts/1,2?format=xml +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 364 + +111blog started212🦀€ Grüßgott, Вiтаю, dobrý deň, hyvää päivää, გამარჯობა, Γεια σας, góðan dag, здравствуйте +=== +PUT /records/posts/1,2?format=xml + +12 +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 54 + +11 diff --git a/tests/functional/001_records/089_redirect_to_ssl.log b/tests/functional/001_records/089_redirect_to_ssl.log new file mode 100644 index 0000000..77ca9c1 --- /dev/null +++ b/tests/functional/001_records/089_redirect_to_ssl.log @@ -0,0 +1,5 @@ +=== +GET http://localhost/records/posts +=== +301 +Location: https://localhost/records/posts diff --git a/tests/functional/002_auth/001_jwt_auth.log b/tests/functional/002_auth/001_jwt_auth.log new file mode 100644 index 0000000..d1f1576 --- /dev/null +++ b/tests/functional/002_auth/001_jwt_auth.log @@ -0,0 +1,44 @@ +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +X-Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6IjE1MzgyMDc2MDUiLCJleHAiOjE1MzgyMDc2MzV9.Z5px_GT15TRKhJCTHhDt5Z6K6LRDSFnLj8U5ok9l7gw +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 45 + +{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 45 + +{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +X-Authorization: Bearer invalid +=== +403 +Content-Type: application/json; charset=utf-8 +Content-Length: 57 + +{"code":1012,"message":"Authentication failed for 'JWT'"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"code":1001,"message":"Table 'invisibles' not found"} +=== +OPTIONS /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +Access-Control-Request-Method: POST +Access-Control-Request-Headers: X-PINGOTHER, Content-Type +=== +200 +Access-Control-Allow-Headers: Content-Type, X-XSRF-TOKEN, X-Authorization +Access-Control-Allow-Methods: OPTIONS, GET, PUT, POST, DELETE, PATCH +Access-Control-Allow-Credentials: true +Access-Control-Max-Age: 1728000 diff --git a/tests/functional/002_auth/002_basic_auth.log b/tests/functional/002_auth/002_basic_auth.log new file mode 100644 index 0000000..df78d03 --- /dev/null +++ b/tests/functional/002_auth/002_basic_auth.log @@ -0,0 +1,34 @@ +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +Authorization: Basic dXNlcm5hbWUxOnBhc3N3b3JkMQ +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 45 + +{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 45 + +{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +Authorization: Basic aW52YWxpZHVzZXI6aW52YWxpZHBhc3M +=== +403 +Content-Type: application/json; charset=utf-8 +Content-Length: 65 + +{"code":1012,"message":"Authentication failed for 'invaliduser'"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"code":1001,"message":"Table 'invisibles' not found"} diff --git a/tests/functional/002_auth/003_db_auth.log b/tests/functional/002_auth/003_db_auth.log new file mode 100644 index 0000000..2e9678e --- /dev/null +++ b/tests/functional/002_auth/003_db_auth.log @@ -0,0 +1,184 @@ +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"code":1001,"message":"Table 'invisibles' not found"} +=== +POST /login +Content-Type: application/json; charset=utf-8 + +{"username":"user2","password":"pass2"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":2,"username":"user2"} +=== +GET /me +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":2,"username":"user2"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 45 + +{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"} +=== +POST /login +Content-Type: application/json; charset=utf-8 + +{"username":"user2","password":"incorect password"} +=== +403 +Content-Type: application/json; charset=utf-8 +Content-Length: 59 + +{"code":1012,"message":"Authentication failed for 'user2'"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 45 + +{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"} +=== +POST /logout +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":2,"username":"user2"} +=== +GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"code":1001,"message":"Table 'invisibles' not found"} +=== +POST /logout +=== +401 +Content-Type: application/json; charset=utf-8 +Content-Length: 49 + +{"code":1011,"message":"Authentication required"} +=== +POST /register +Content-Type: application/json; charset=utf-8 + +{"username":"user2","password":""} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 60 + +{"code":1021,"message":"Password too short (<4 characters)"} +=== +POST /register +Content-Type: application/json; charset=utf-8 + +{"username":"user2","password":"pass2"} +=== +409 +Content-Type: application/json; charset=utf-8 +Content-Length: 53 + +{"code":1020,"message":"User 'user2' already exists"} +=== +POST /register +Content-Type: application/json; charset=utf-8 + +{"username":"user3","password":"pass3"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":3,"username":"user3"} +=== +POST /login +Content-Type: application/json; charset=utf-8 + +{"username":"user3","password":"pass3"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":3,"username":"user3"} +=== +GET /me +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":3,"username":"user3"} +=== +POST /password +Content-Type: application/json; charset=utf-8 + +{"username":"user3","password":"pass3","newPassword":"secret3"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":3,"username":"user3"} +=== +POST /logout +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":3,"username":"user3"} +=== +POST /login +Content-Type: application/json; charset=utf-8 + +{"username":"user3","password":"secret3"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":3,"username":"user3"} +=== +GET /me +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":3,"username":"user3"} +=== +POST /logout +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 27 + +{"id":3,"username":"user3"} +=== +DELETE /records/users/3 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 diff --git a/tests/functional/003_columns/001_get_database.log b/tests/functional/003_columns/001_get_database.log new file mode 100644 index 0000000..97ca1f5 --- /dev/null +++ b/tests/functional/003_columns/001_get_database.log @@ -0,0 +1,9 @@ +skip-for-sqlite: auto incrementing primary keys must be integer typed (may not be bigint) +=== +GET /columns +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 2840 + +{"tables":[{"name":"barcodes","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"product_id","type":"integer","fk":"products"},{"name":"hex","type":"varchar","length":255},{"name":"bin","type":"blob"},{"name":"ip_address","type":"varchar","length":15,"nullable":true}]},{"name":"categories","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"icon","type":"blob","nullable":true}]},{"name":"comments","type":"table","columns":[{"name":"id","type":"bigint","pk":true},{"name":"post_id","type":"integer","fk":"posts"},{"name":"message","type":"varchar","length":255},{"name":"category_id","type":"integer","fk":"categories"}]},{"name":"countries","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"shape","type":"geometry"}]},{"name":"events","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"datetime","type":"timestamp","nullable":true},{"name":"visitors","type":"bigint","nullable":true}]},{"name":"kunsthåndværk","type":"table","columns":[{"name":"id","type":"varchar","length":36,"pk":true},{"name":"Umlauts ä_ö_ü-COUNT","type":"integer"},{"name":"user_id","type":"integer","fk":"users"},{"name":"invisible_id","type":"varchar","length":36,"nullable":true,"fk":"invisibles"}]},{"name":"nopk","type":"table","columns":[{"name":"id","type":"varchar","length":36}]},{"name":"post_tags","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"post_id","type":"integer","fk":"posts"},{"name":"tag_id","type":"integer","fk":"tags"}]},{"name":"posts","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"user_id","type":"integer","fk":"users"},{"name":"category_id","type":"integer","fk":"categories"},{"name":"content","type":"varchar","length":255}]},{"name":"products","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"price","type":"decimal","precision":10,"scale":2},{"name":"properties","type":"clob"},{"name":"created_at","type":"timestamp"},{"name":"deleted_at","type":"timestamp","nullable":true}]},{"name":"tag_usage","type":"view","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"count","type":"bigint"}]},{"name":"tags","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"is_important","type":"boolean"}]},{"name":"users","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"username","type":"varchar","length":255},{"name":"password","type":"varchar","length":255},{"name":"location","type":"geometry","nullable":true}]}]} diff --git a/tests/functional/003_columns/002_get_barcodes_table.log b/tests/functional/003_columns/002_get_barcodes_table.log new file mode 100644 index 0000000..83a4e3a --- /dev/null +++ b/tests/functional/003_columns/002_get_barcodes_table.log @@ -0,0 +1,8 @@ +=== +GET /columns/barcodes +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 283 + +{"name":"barcodes","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"product_id","type":"integer","fk":"products"},{"name":"hex","type":"varchar","length":255},{"name":"bin","type":"blob"},{"name":"ip_address","type":"varchar","length":15,"nullable":true}]} diff --git a/tests/functional/003_columns/003_get_barcodes_id_column.log b/tests/functional/003_columns/003_get_barcodes_id_column.log new file mode 100644 index 0000000..128b7a2 --- /dev/null +++ b/tests/functional/003_columns/003_get_barcodes_id_column.log @@ -0,0 +1,8 @@ +=== +GET /columns/barcodes/id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 40 + +{"name":"id","type":"integer","pk":true} diff --git a/tests/functional/003_columns/004_update_barcodes_id_column.log b/tests/functional/003_columns/004_update_barcodes_id_column.log new file mode 100644 index 0000000..3478567 --- /dev/null +++ b/tests/functional/003_columns/004_update_barcodes_id_column.log @@ -0,0 +1,37 @@ +skip-for-sqlite: table (columns) cannot be altered online +=== +PUT /columns/barcodes/id + +{"name":"id2","type":"bigint"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/id2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 40 + +{"name":"id2","type":"bigint","pk":true} +=== +PUT /columns/barcodes/id2 + +{"name":"id","type":"integer"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 40 + +{"name":"id","type":"integer","pk":true} diff --git a/tests/functional/003_columns/005_update_barcodes_product_id_nullable.log b/tests/functional/003_columns/005_update_barcodes_product_id_nullable.log new file mode 100644 index 0000000..6363c9c --- /dev/null +++ b/tests/functional/003_columns/005_update_barcodes_product_id_nullable.log @@ -0,0 +1,37 @@ +skip-for-sqlite: table (columns) cannot be altered online +=== +PUT /columns/barcodes/product_id + +{"nullable":true} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/product_id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"name":"product_id","type":"integer","nullable":true,"fk":"products"} +=== +PUT /columns/barcodes/product_id + +{"nullable":false} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/product_id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"name":"product_id","type":"integer","fk":"products"} diff --git a/tests/functional/003_columns/006_update_events_visitors_pk.log b/tests/functional/003_columns/006_update_events_visitors_pk.log new file mode 100644 index 0000000..7de24a4 --- /dev/null +++ b/tests/functional/003_columns/006_update_events_visitors_pk.log @@ -0,0 +1,81 @@ +skip-for-sqlite: table (columns) cannot be altered online +=== +GET /columns/events/visitors +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 51 + +{"name":"visitors","type":"bigint","nullable":true} +=== +PUT /columns/events/visitors + +{"nullable":false} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/events/visitors +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 35 + +{"name":"visitors","type":"bigint"} +=== +PUT /columns/events/visitors + +{"nullable":true} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/events/id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 40 + +{"name":"id","type":"integer","pk":true} +=== +PUT /columns/events/visitors + +{"pk":false} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/events/visitors +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 51 + +{"name":"visitors","type":"bigint","nullable":true} +=== +PUT /columns/events/id + +{"pk":true} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/events/id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 40 + +{"name":"id","type":"integer","pk":true} diff --git a/tests/functional/003_columns/007_update_barcodes_product_id_fk.log b/tests/functional/003_columns/007_update_barcodes_product_id_fk.log new file mode 100644 index 0000000..5ad3944 --- /dev/null +++ b/tests/functional/003_columns/007_update_barcodes_product_id_fk.log @@ -0,0 +1,37 @@ +skip-for-sqlite: table (columns) cannot be altered online +=== +PUT /columns/barcodes/product_id + +{"fk":""} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/product_id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 38 + +{"name":"product_id","type":"integer"} +=== +PUT /columns/barcodes/product_id + +{"fk":"products"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/product_id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"name":"product_id","type":"integer","fk":"products"} diff --git a/tests/functional/003_columns/008_update_barcodes_table.log b/tests/functional/003_columns/008_update_barcodes_table.log new file mode 100644 index 0000000..957fee4 --- /dev/null +++ b/tests/functional/003_columns/008_update_barcodes_table.log @@ -0,0 +1,36 @@ +=== +PUT /columns/barcodes + +{"name":"barcodes2"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 284 + +{"name":"barcodes2","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"product_id","type":"integer","fk":"products"},{"name":"hex","type":"varchar","length":255},{"name":"bin","type":"blob"},{"name":"ip_address","type":"varchar","length":15,"nullable":true}]} +=== +PUT /columns/barcodes2 + +{"name":"barcodes"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 283 + +{"name":"barcodes","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"product_id","type":"integer","fk":"products"},{"name":"hex","type":"varchar","length":255},{"name":"bin","type":"blob"},{"name":"ip_address","type":"varchar","length":15,"nullable":true}]} diff --git a/tests/functional/003_columns/009_update_barcodes_hex_type.log b/tests/functional/003_columns/009_update_barcodes_hex_type.log new file mode 100644 index 0000000..d00f34e --- /dev/null +++ b/tests/functional/003_columns/009_update_barcodes_hex_type.log @@ -0,0 +1,37 @@ +skip-for-sqlite: table (columns) cannot be altered online +=== +PUT /columns/barcodes/hex + +{"type":"clob"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/hex +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 28 + +{"name":"hex","type":"clob"} +=== +PUT /columns/barcodes/hex + +{"type":"varchar","length":255} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/hex +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 44 + +{"name":"hex","type":"varchar","length":255} diff --git a/tests/functional/003_columns/010_create_barcodes_table.log b/tests/functional/003_columns/010_create_barcodes_table.log new file mode 100644 index 0000000..b55e42c --- /dev/null +++ b/tests/functional/003_columns/010_create_barcodes_table.log @@ -0,0 +1,26 @@ +=== +POST /columns + +{"name":"barcodes2","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"product_id","type":"integer","fk":"products"},{"name":"hex","type":"varchar","length":255},{"name":"bin","type":"blob"},{"name":"ip_address","type":"varchar","length":15,"nullable":true}]} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 284 + +{"name":"barcodes2","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"product_id","type":"integer","fk":"products"},{"name":"hex","type":"varchar","length":255},{"name":"bin","type":"blob"},{"name":"ip_address","type":"varchar","length":15,"nullable":true}]} +=== +DELETE /columns/barcodes2 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true diff --git a/tests/functional/003_columns/011_create_barcodes_column.log b/tests/functional/003_columns/011_create_barcodes_column.log new file mode 100644 index 0000000..c72defa --- /dev/null +++ b/tests/functional/003_columns/011_create_barcodes_column.log @@ -0,0 +1,27 @@ +skip-for-sqlite: table (columns) cannot be altered online +=== +POST /columns/barcodes + +{"name":"alternative_product_id","type":"integer","nullable":true,"fk":"products"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/barcodes/alternative_product_id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 82 + +{"name":"alternative_product_id","type":"integer","nullable":true,"fk":"products"} +=== +DELETE /columns/barcodes/alternative_product_id +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true diff --git a/tests/functional/003_columns/012_get_invisibles_table.log b/tests/functional/003_columns/012_get_invisibles_table.log new file mode 100644 index 0000000..24fb9a7 --- /dev/null +++ b/tests/functional/003_columns/012_get_invisibles_table.log @@ -0,0 +1,8 @@ +=== +GET /columns/invisibles +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"code":1001,"message":"Table 'invisibles' not found"} diff --git a/tests/functional/003_columns/013_get_invisible_column.log b/tests/functional/003_columns/013_get_invisible_column.log new file mode 100644 index 0000000..6e81a03 --- /dev/null +++ b/tests/functional/003_columns/013_get_invisible_column.log @@ -0,0 +1,8 @@ +=== +GET /columns/kunsthåndværk/invisible +=== +404 +Content-Type: application/json; charset=utf-8 +Content-Length: 54 + +{"code":1005,"message":"Column 'invisible' not found"} diff --git a/tests/functional/003_columns/014_create_types_table.log b/tests/functional/003_columns/014_create_types_table.log new file mode 100644 index 0000000..1c12ba6 --- /dev/null +++ b/tests/functional/003_columns/014_create_types_table.log @@ -0,0 +1,56 @@ +=== +POST /columns + +{"name":"types","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"integer","type":"integer"},{"name":"bigint","type":"bigint"},{"name":"varchar","type":"varchar","length":10},{"name":"decimal","type":"decimal","precision":10,"scale":4},{"name":"float","type":"float"},{"name":"double","type":"double"},{"name":"boolean","type":"boolean"},{"name":"date","type":"date"},{"name":"time","type":"time"},{"name":"timestamp","type":"timestamp"},{"name":"clob","type":"clob"},{"name":"blob","type":"blob"},{"name":"geometry","type":"geometry"}]} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +GET /columns/types +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 563 + +{"name":"types","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"integer","type":"integer"},{"name":"bigint","type":"bigint"},{"name":"varchar","type":"varchar","length":10},{"name":"decimal","type":"decimal","precision":10,"scale":4},{"name":"float","type":"float"},{"name":"double","type":"double"},{"name":"boolean","type":"boolean"},{"name":"date","type":"date"},{"name":"time","type":"time"},{"name":"timestamp","type":"timestamp"},{"name":"clob","type":"clob"},{"name":"blob","type":"blob"},{"name":"geometry","type":"geometry"}]} +=== +POST /records/types +Content-Type: application/json; charset=utf-8 + +{"integer":3,"bigint":4,"varchar":"bcd","decimal":"2.34","float":2,"double":34.56,"boolean":false,"date":"2020-02-02","time":"23:55:59","timestamp":"2002-03-04 05:06:07","clob":"b","blob":"Yg==","geometry":"POINT(2 3)"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PUT /records/types/1 +Content-Type: application/json; charset=utf-8 + +{"integer":2,"bigint":3,"varchar":"abc","decimal":"1.23","float":1,"double":23.45,"boolean":true,"date":"1970-01-01","time":"00:00:01","timestamp":"2001-02-03 04:05:06","clob":"a","blob":"YQ==","geometry":"POINT(1 2)"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/types/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 227 + +{"id":1,"integer":2,"bigint":3,"varchar":"abc","decimal":"1.2300","float":1,"double":23.45,"boolean":true,"date":"1970-01-01","time":"00:00:01","timestamp":"2001-02-03 04:05:06","clob":"a","blob":"YQ==","geometry":"POINT(1 2)"} +=== +DELETE /columns/types +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true diff --git a/tests/functional/003_columns/015_update_types_table.log b/tests/functional/003_columns/015_update_types_table.log new file mode 100644 index 0000000..fde08e1 --- /dev/null +++ b/tests/functional/003_columns/015_update_types_table.log @@ -0,0 +1,337 @@ +=== +POST /columns + +{"name":"types","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"integer","type":"integer"},{"name":"bigint","type":"bigint"},{"name":"varchar","type":"varchar","length":10},{"name":"decimal","type":"decimal","precision":10,"scale":4},{"name":"float","type":"float"},{"name":"double","type":"double"},{"name":"boolean","type":"boolean"},{"name":"date","type":"date"},{"name":"time","type":"time"},{"name":"timestamp","type":"timestamp"},{"name":"clob","type":"clob"},{"name":"blob","type":"blob"},{"name":"geometry","type":"geometry"}]} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +POST /records/types +Content-Type: application/json; charset=utf-8 + +{"integer":2,"bigint":3,"varchar":"abc","decimal":"1.23","float":1,"double":23.45,"boolean":true,"date":"1970-01-01","time":"00:00:01","timestamp":"2001-02-03 04:05:06","clob":"a","blob":"YQ==","geometry":"POINT(1 2)"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PUT /records/types/1 + +{"boolean":null} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 100 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"boolean":"cannot be null"}} +=== +PUT /records/types/1 + +{"integer":" 1\n"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 104 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"integer":"illegal whitespace"}} +=== +PUT /records/types/1 + +{"integer":"23e"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"integer":"invalid integer"}} +=== +PUT /records/types/1 + +integer=23e +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"integer":"invalid integer"}} +=== +PUT /records/types/1 + +{"integer":"2.3"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"integer":"invalid integer"}} +=== +PUT /records/types/1 + +{"integer":2.3} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"integer":"invalid integer"}} +=== +PUT /records/types/1 + +{"integer":"12345678901"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"integer":"invalid integer"}} +=== +PUT /records/types/1 + +{"bigint":"12345678901234567890"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 100 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"bigint":"invalid integer"}} +=== +PUT /records/types/1 + +{"varchar":"12345678901"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"varchar":"string too long"}} +=== +PUT /records/types/1 + +{"decimal":"-1.23"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PUT /records/types/1 + +{"decimal":"1.23"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PUT /records/types/1 + +{"decimal":"12.23.34"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"decimal":"invalid decimal"}} +=== +PUT /records/types/1 + +{"decimal":"1131313145345"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 103 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"decimal":"decimal too large"}} +=== +PUT /records/types/1 + +{"decimal":1.2300} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"decimal":"invalid decimal"}} +=== +PUT /records/types/1 + +{"decimal":"1234567.123"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 103 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"decimal":"decimal too large"}} +=== +PUT /records/types/1 + +{"decimal":"123456.12345"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 105 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"decimal":"decimal too precise"}} +=== +PUT /records/types/1 + +{"decimal":"113131.3145345"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 105 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"decimal":"decimal too precise"}} +=== +PUT /records/types/1 + +{"float":"string"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 97 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"float":"invalid float"}} +=== +PUT /records/types/1 + +{"double":"string"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 98 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"double":"invalid float"}} +=== +PUT /records/types/1 + +{"boolean":-1} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"boolean":"invalid boolean"}} +=== +PUT /records/types/1 + +{"date":"string"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 95 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"date":"invalid date"}} +=== +PUT /records/types/1 + +{"date":"still-no-date"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 95 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"date":"invalid date"}} +=== +PUT /records/types/1 + +{"time":"string"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 95 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"time":"invalid time"}} +=== +PUT /records/types/1 + +{"time":"still:no:time"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 95 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"time":"invalid time"}} +=== +PUT /records/types/1 + +{"time":"999:999:999"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 95 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"time":"invalid time"}} +=== +PUT /records/types/1 + +{"timestamp":"string"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 105 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"timestamp":"invalid timestamp"}} +=== +PUT /records/types/1 + +{"timestamp":"2001-01-01 999:999:999"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 105 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"timestamp":"invalid timestamp"}} +=== +PUT /records/types/1 + +{"clob":"𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PUT /records/types/1 + +{"blob":"!"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 97 + +{"code":1013,"message":"Input validation failed for 'types'","details":{"blob":"invalid base64"}} +=== +PUT /records/types/1 + +{"blob":"T8O5IGVzdCBsZSBjYWbDqSBsZSBwbHVzIHByb2NoZT8"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/types/1 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 334 + +{"id":1,"integer":2,"bigint":3,"varchar":"abc","decimal":"1.2300","float":1,"double":23.45,"boolean":true,"date":"1970-01-01","time":"00:00:01","timestamp":"2001-02-03 04:05:06","clob":"𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗","blob":"T8O5IGVzdCBsZSBjYWbDqSBsZSBwbHVzIHByb2NoZT8=","geometry":"POINT(1 2)"} +=== +DELETE /columns/types +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true diff --git a/tests/functional/003_columns/016_update_forgiving_table.log b/tests/functional/003_columns/016_update_forgiving_table.log new file mode 100644 index 0000000..607f9ff --- /dev/null +++ b/tests/functional/003_columns/016_update_forgiving_table.log @@ -0,0 +1,401 @@ +=== +POST /columns + +{"name":"forgiving","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"integer","type":"integer"},{"name":"bigint","type":"bigint"},{"name":"varchar","type":"varchar","length":10},{"name":"decimal","type":"decimal","precision":10,"scale":4},{"name":"float","type":"float"},{"name":"double","type":"double"},{"name":"boolean","type":"boolean"},{"name":"date","type":"date"},{"name":"time","type":"time"},{"name":"timestamp","type":"timestamp"},{"name":"clob","type":"clob"},{"name":"blob","type":"blob"},{"name":"geometry","type":"geometry"}]} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true +=== +POST /records/forgiving +Content-Type: application/json; charset=utf-8 + +{"integer":2,"bigint":3,"varchar":"abc","decimal":1.23,"float":1,"double":23.45,"boolean":true,"date":"1970-01-01","time":"00:00:01","timestamp":"2001-02-03 04:05:06","clob":"a","blob":"YQ==","geometry":"POINT(1 2)"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PUT /records/forgiving/1 + +{"boolean":"true"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=boolean +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 16 + +{"boolean":true} +=== +PUT /records/forgiving/1 + +{"boolean":"yes"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=boolean +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 16 + +{"boolean":true} +=== +PUT /records/forgiving/1 + +{"boolean":"1"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=boolean +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 16 + +{"boolean":true} +=== +PUT /records/forgiving/1 + +{"boolean":1} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=boolean +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 16 + +{"boolean":true} +=== +PUT /records/forgiving/1 + +{"integer":" 2\n"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=integer +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 13 + +{"integer":2} +=== +PUT /records/forgiving/1 + +integer=2%20 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=integer +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 13 + +{"integer":2} +=== +PUT /records/forgiving/1 + +{"integer":1.99999} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=integer +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 13 + +{"integer":2} +=== +PUT /records/forgiving/1 + +{"bigint":" 3\n"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=bigint +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 12 + +{"bigint":3} +=== +PUT /records/forgiving/1 + +{"bigint":2.99999} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=bigint +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 12 + +{"bigint":3} +=== +PUT /records/forgiving/1 + +{"decimal":"1.23"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=decimal +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 20 + +{"decimal":"1.2300"} +=== +PUT /records/forgiving/1 + +{"decimal":1.2300} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=decimal +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 20 + +{"decimal":"1.2300"} +=== +PUT /records/forgiving/1 + +{"decimal":"1.23004"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=decimal +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 20 + +{"decimal":"1.2300"} +=== +PUT /records/forgiving/1 + +{"decimal":"1.23006"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=decimal +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 20 + +{"decimal":"1.2301"} +=== +PUT /records/forgiving/1 + +float=1%20 +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=float +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 11 + +{"float":1} +=== +PUT /records/forgiving/1 + +{"double":" 23.45e-1 "} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=double +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 16 + +{"double":2.345} +=== +PUT /records/forgiving/1 + +{"date":"2020-12-05"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=date +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 21 + +{"date":"2020-12-05"} +=== +PUT /records/forgiving/1 + +{"date":"December 20th, 2020"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=date +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 21 + +{"date":"2020-12-20"} +=== +PUT /records/forgiving/1 + +{"time":"13:15"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=time +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 19 + +{"time":"13:15:00"} +=== +PUT /records/forgiving/1 + +{"timestamp":"2012-1-1 23:46"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +GET /records/forgiving/1?include=timestamp +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 35 + +{"timestamp":"2012-01-01 23:46:00"} +=== +PUT /records/forgiving/1 + +{"clob":"𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +PUT /records/forgiving/1 + +{"blob":"!"} +=== +422 +Content-Type: application/json; charset=utf-8 +Content-Length: 101 + +{"code":1013,"message":"Input validation failed for 'forgiving'","details":{"blob":"invalid base64"}} +=== +PUT /records/forgiving/1 + +{"blob":"T8O5IGVzdCBsZSBjYWbDqSBsZSBwbHVzIHByb2NoZT8"} +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 1 + +1 +=== +DELETE /columns/forgiving +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true diff --git a/tests/functional/003_columns/017_get_barcodes_table_as_xml.log b/tests/functional/003_columns/017_get_barcodes_table_as_xml.log new file mode 100644 index 0000000..0d5bc06 --- /dev/null +++ b/tests/functional/003_columns/017_get_barcodes_table_as_xml.log @@ -0,0 +1,8 @@ +=== +GET /columns/barcodes?format=xml +=== +200 +Content-Type: text/xml; charset=utf-8 +Content-Length: 433 + +barcodestableidintegertrueproduct_idintegerproductshexvarchar255binblobip_addressvarchar15true diff --git a/tests/functional/004_cache/001_clear_cache.log b/tests/functional/004_cache/001_clear_cache.log new file mode 100644 index 0000000..c808b4a --- /dev/null +++ b/tests/functional/004_cache/001_clear_cache.log @@ -0,0 +1,8 @@ +=== +GET /cache/clear +=== +200 +Content-Type: application/json; charset=utf-8 +Content-Length: 4 + +true diff --git a/update.php b/update.php new file mode 100644 index 0000000..f8939b6 --- /dev/null +++ b/update.php @@ -0,0 +1,11 @@ +